@vintasoftware/pr-review-canvas 0.2.0 → 0.4.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/README.md +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
|
@@ -27,13 +27,25 @@ import { flash, scrollIntoViewSafe } from './dom.js'
|
|
|
27
27
|
import { refreshProgress } from './header.js'
|
|
28
28
|
import { keyAction, openHelpDialog } from './keyboard.js'
|
|
29
29
|
import { pointAnchorId, sanitizeKey } from './keys.js'
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
getRenderContext,
|
|
32
|
+
pathSet,
|
|
33
|
+
setCardRenderedHook,
|
|
34
|
+
setRenderContext,
|
|
35
|
+
updateRenderState,
|
|
36
|
+
} from './layers.js'
|
|
31
37
|
import { buildNavOrder, layerOf, nextFile, nextLayer, prevFile, prevLayer } from './nav.js'
|
|
32
38
|
import { issueCommentHtml } from './overview.js'
|
|
33
39
|
import { applyDismissed, pointToMarkdown, postedUrls } from './points.js'
|
|
34
40
|
import { layerProgress } from './progress.js'
|
|
35
41
|
import { lineRefFromEvent, markSelection, selectionReducer } from './selection.js'
|
|
36
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
fillSignoffDialog,
|
|
44
|
+
openSignoffDialog,
|
|
45
|
+
showSignoffError,
|
|
46
|
+
showSignoffResult,
|
|
47
|
+
signoffBody,
|
|
48
|
+
} from './signoff.js'
|
|
37
49
|
|
|
38
50
|
/** @typedef {NonNullable<ReturnType<typeof import('./chat.js').wireChat>>} ChatHandle */
|
|
39
51
|
|
|
@@ -148,7 +160,8 @@ export function nextUnreviewedTarget(root, session, fromId, kind) {
|
|
|
148
160
|
if (item === undefined || item.kind !== kind || item.other) {
|
|
149
161
|
continue
|
|
150
162
|
}
|
|
151
|
-
const id =
|
|
163
|
+
const id =
|
|
164
|
+
item.kind === 'file' ? `layer:${item.layerId}/file:${sanitizeKey(item.path)}` : `layer:${item.layerId}`
|
|
152
165
|
if (!session.isReviewed(id)) {
|
|
153
166
|
return root.querySelector(`#${cssEscape(item.id)}`)
|
|
154
167
|
}
|
|
@@ -167,7 +180,13 @@ export function nextUnreviewedTarget(root, session, fromId, kind) {
|
|
|
167
180
|
*/
|
|
168
181
|
export function askTargetFor(root, focusId, focusPointId, selection) {
|
|
169
182
|
if (selection !== null) {
|
|
170
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
kind: 'lines',
|
|
185
|
+
path: selection.path,
|
|
186
|
+
side: selection.side,
|
|
187
|
+
start: selection.start,
|
|
188
|
+
end: selection.end,
|
|
189
|
+
}
|
|
171
190
|
}
|
|
172
191
|
if (focusPointId !== null) {
|
|
173
192
|
const el = root.querySelector(`[data-point="${cssEscape(focusPointId)}"] [data-act="ask"]`)
|
|
@@ -213,7 +232,8 @@ export function wireReview(root, session, opts = {}) {
|
|
|
213
232
|
|
|
214
233
|
const paths = () => pathSet(getRenderContext()?.files ?? [])
|
|
215
234
|
/** The diff key of a path, which is what the row ids are built from. */
|
|
216
|
-
const keyForPath = (/** @type {string} */ path) =>
|
|
235
|
+
const keyForPath = (/** @type {string} */ path) =>
|
|
236
|
+
getRenderContext()?.files.find(f => f.path === path)?.key ?? null
|
|
217
237
|
/** The time the page was drawn, which the comments it adds are timed against. */
|
|
218
238
|
const renderNow = () => getRenderContext()?.now ?? new Date()
|
|
219
239
|
|
|
@@ -263,7 +283,10 @@ export function wireReview(root, session, opts = {}) {
|
|
|
263
283
|
const answer = await session.postComment(input)
|
|
264
284
|
const ctx = getRenderContext()
|
|
265
285
|
if (answer.kind === 'review' && ctx !== null) {
|
|
266
|
-
setRenderContext({
|
|
286
|
+
setRenderContext({
|
|
287
|
+
...ctx,
|
|
288
|
+
comments: [...ctx.comments.filter(c => c.id !== answer.comment.id), answer.comment],
|
|
289
|
+
})
|
|
267
290
|
onState(session.state)
|
|
268
291
|
}
|
|
269
292
|
return answer
|
|
@@ -325,7 +348,8 @@ export function wireReview(root, session, opts = {}) {
|
|
|
325
348
|
*/
|
|
326
349
|
const openComposer = (anchor, options, shape) => {
|
|
327
350
|
closeComposers(root)
|
|
328
|
-
const node =
|
|
351
|
+
const node =
|
|
352
|
+
shape === 'row' ? rowFrom(doc, composerRowHtml(options)) : nodeFrom(doc, composerHtml(options))
|
|
329
353
|
if (node === null) {
|
|
330
354
|
return null
|
|
331
355
|
}
|
|
@@ -364,7 +388,10 @@ export function wireReview(root, session, opts = {}) {
|
|
|
364
388
|
}
|
|
365
389
|
|
|
366
390
|
/** The inline comment that a posted review comment becomes on the page. */
|
|
367
|
-
const insertPostedReviewComment = (
|
|
391
|
+
const insertPostedReviewComment = (
|
|
392
|
+
/** @type {Element} */ composerNode,
|
|
393
|
+
/** @type {ReviewComment} */ comment
|
|
394
|
+
) => {
|
|
368
395
|
const thread = root.querySelector(
|
|
369
396
|
`tr.thread[data-thread="${cssEscape(String(comment.inReplyToId ?? comment.id))}"]`
|
|
370
397
|
)
|
|
@@ -461,9 +488,9 @@ export function wireReview(root, session, opts = {}) {
|
|
|
461
488
|
* @param {string} id
|
|
462
489
|
* @param {boolean} reviewed
|
|
463
490
|
* @param {'layer' | 'file'} kind
|
|
464
|
-
* @param {{ quiet?: boolean }} [
|
|
491
|
+
* @param {{ quiet?: boolean }} [reviewOptions] `quiet` keeps a checkbox label intact while it runs
|
|
465
492
|
*/
|
|
466
|
-
const markReviewed = (button, id, reviewed, kind,
|
|
493
|
+
const markReviewed = (button, id, reviewed, kind, reviewOptions = {}) => {
|
|
467
494
|
const run = async () => {
|
|
468
495
|
await session.setReviewed(id, reviewed)
|
|
469
496
|
const parts = cardForReviewedId(root, id)
|
|
@@ -479,7 +506,7 @@ export function wireReview(root, session, opts = {}) {
|
|
|
479
506
|
}
|
|
480
507
|
toast(root, reviewed ? `${kind} marked reviewed` : `${kind} reopened`)
|
|
481
508
|
}
|
|
482
|
-
void (
|
|
509
|
+
void (reviewOptions.quiet === true
|
|
483
510
|
? runControl(button, run)
|
|
484
511
|
: runCommand(button, run, { pendingLabel: reviewed ? 'marking…' : 'unmarking…' }))
|
|
485
512
|
}
|
|
@@ -589,8 +616,7 @@ export function wireReview(root, session, opts = {}) {
|
|
|
589
616
|
postFromComposer(el, box)
|
|
590
617
|
}
|
|
591
618
|
},
|
|
592
|
-
'markdown-
|
|
593
|
-
'markdown-write': el => toggleMarkdownPreview(el, false),
|
|
619
|
+
'markdown-toggle': el => toggleMarkdownPreview(el),
|
|
594
620
|
'composer-cancel': () => {
|
|
595
621
|
closeComposers(root)
|
|
596
622
|
},
|
|
@@ -641,7 +667,11 @@ export function wireReview(root, session, opts = {}) {
|
|
|
641
667
|
return
|
|
642
668
|
}
|
|
643
669
|
composerSeq += 1
|
|
644
|
-
openComposer(
|
|
670
|
+
openComposer(
|
|
671
|
+
full,
|
|
672
|
+
{ id: `composer-${composerSeq}`, label: 'Reply', kind: 'reply', inReplyToId: id },
|
|
673
|
+
'block'
|
|
674
|
+
)
|
|
645
675
|
},
|
|
646
676
|
'pr-comment': () => {
|
|
647
677
|
const host = root.querySelector('.conversation .pr-composer-host')
|
|
@@ -733,7 +763,10 @@ export function wireReview(root, session, opts = {}) {
|
|
|
733
763
|
}
|
|
734
764
|
event.preventDefault()
|
|
735
765
|
setSelection(
|
|
736
|
-
selectionReducer(
|
|
766
|
+
selectionReducer(
|
|
767
|
+
selection,
|
|
768
|
+
event.shiftKey ? { type: 'shift-click', target } : { type: 'click', target }
|
|
769
|
+
)
|
|
737
770
|
)
|
|
738
771
|
if (selection !== null && !event.shiftKey) {
|
|
739
772
|
setSelection(selectionReducer(selection, { type: 'drag-start', target }))
|
|
@@ -807,12 +840,18 @@ export function wireReview(root, session, opts = {}) {
|
|
|
807
840
|
const item = order.find(i => i.id === focusId)
|
|
808
841
|
const wanted = decided.action === 'reviewed-file' ? 'file' : 'layer'
|
|
809
842
|
const found =
|
|
810
|
-
item?.kind === wanted
|
|
843
|
+
item?.kind === wanted
|
|
844
|
+
? item
|
|
845
|
+
: wanted === 'layer' && focusId !== null
|
|
846
|
+
? layerOf(order, focusId)
|
|
847
|
+
: null
|
|
811
848
|
if (found === null || found === undefined || found.kind === 'overview') {
|
|
812
849
|
break
|
|
813
850
|
}
|
|
814
851
|
const id =
|
|
815
|
-
found.kind === 'file'
|
|
852
|
+
found.kind === 'file'
|
|
853
|
+
? `layer:${found.layerId}/file:${sanitizeKey(found.path)}`
|
|
854
|
+
: `layer:${found.layerId}`
|
|
816
855
|
const parts = cardForReviewedId(root, id)
|
|
817
856
|
const box = parts?.card.querySelector('input[data-reviewed-id]')
|
|
818
857
|
if (box instanceof HTMLElement) {
|
package/static/js/keyboard.js
CHANGED
|
@@ -35,7 +35,10 @@ export function isTypingTarget(target) {
|
|
|
35
35
|
}
|
|
36
36
|
const tag = target.tagName.toLowerCase()
|
|
37
37
|
return (
|
|
38
|
-
tag === 'input' ||
|
|
38
|
+
tag === 'input' ||
|
|
39
|
+
tag === 'textarea' ||
|
|
40
|
+
tag === 'select' ||
|
|
41
|
+
target.closest('[contenteditable="true"]') !== null
|
|
39
42
|
)
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -97,7 +100,9 @@ export function keyAction(event, opts = {}) {
|
|
|
97
100
|
export const HELP_DIALOG_ID = 'help-dialog'
|
|
98
101
|
|
|
99
102
|
export function helpDialogHtml() {
|
|
100
|
-
const rows = KEY_HELP.map(r => `<tr><td class="mono">${esc(r.keys)}</td><td>${esc(r.what)}</td></tr>`).join(
|
|
103
|
+
const rows = KEY_HELP.map(r => `<tr><td class="mono">${esc(r.keys)}</td><td>${esc(r.what)}</td></tr>`).join(
|
|
104
|
+
''
|
|
105
|
+
)
|
|
101
106
|
return (
|
|
102
107
|
`<dialog id="${HELP_DIALOG_ID}" class="help" aria-labelledby="help-h"><form method="dialog">` +
|
|
103
108
|
'<h2 id="help-h">Keyboard</h2>' +
|
package/static/js/layers.js
CHANGED
|
@@ -77,7 +77,7 @@ export function pathSet(files) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Layer title per hunk id, so a file card can say "2 more
|
|
80
|
+
* Layer title per hunk id, so a file card can say "2 more chunks in layer 4".
|
|
81
81
|
* @param {ReviewArtifact} artifact
|
|
82
82
|
* @returns {Map<string, { layer: Layer, index: number }>}
|
|
83
83
|
*/
|
|
@@ -117,7 +117,8 @@ export function railInnerHtml(artifact, state, opts = {}) {
|
|
|
117
117
|
.map((layer, i) => {
|
|
118
118
|
const progress = layerProgress(layer, state)
|
|
119
119
|
const dotClass = progress === 'done' ? 'dot on' : progress === 'partial' ? 'dot half' : 'dot'
|
|
120
|
-
const dotLabel =
|
|
120
|
+
const dotLabel =
|
|
121
|
+
progress === 'done' ? 'reviewed' : progress === 'partial' ? 'in progress' : 'not started'
|
|
121
122
|
const reviewed = filesReviewed(layer, state)
|
|
122
123
|
const files = layer.files.length
|
|
123
124
|
const meta = reviewed > 0 && reviewed < files ? `${reviewed} of ${files} files` : fileCount(files)
|
|
@@ -206,7 +207,11 @@ export function layerDiagramHtml(layer) {
|
|
|
206
207
|
* @param {ReadonlySet<string>} paths
|
|
207
208
|
*/
|
|
208
209
|
export function judgmentHtml(layer, paths) {
|
|
209
|
-
const block = /** @param {string} cls @param {string} label @param {string | undefined} text */ (
|
|
210
|
+
const block = /** @param {string} cls @param {string} label @param {string | undefined} text */ (
|
|
211
|
+
cls,
|
|
212
|
+
label,
|
|
213
|
+
text
|
|
214
|
+
) =>
|
|
210
215
|
text === undefined
|
|
211
216
|
? ''
|
|
212
217
|
: `<div class="judgment ${cls}"><h3 class="lbl">${label}</h3><div class="prose">${renderMarkdown(text, { paths })}</div></div>`
|
|
@@ -230,7 +235,9 @@ export function layerPointsHtml(layer, points, paths, state, posted) {
|
|
|
230
235
|
return ''
|
|
231
236
|
}
|
|
232
237
|
const active = own.filter(p => state.dismissed[p.fingerprint] === undefined).length
|
|
233
|
-
const cards = own.map(p =>
|
|
238
|
+
const cards = own.map(p =>
|
|
239
|
+
pointCardHtml(p, posted === undefined ? { paths, state } : { paths, state, posted })
|
|
240
|
+
)
|
|
234
241
|
return `<h3 class="lbl sub">Attention points · <span class="point-count">${active}</span></h3><ol class="findings">${cards.join('')}</ol>`
|
|
235
242
|
}
|
|
236
243
|
|
|
@@ -245,14 +252,16 @@ export function renderFileCard(lf, entry, layer, ctx) {
|
|
|
245
252
|
const key = entry?.key ?? sanitizeKey(lf.path)
|
|
246
253
|
const cardReviewedId = `layer:${layer.id}/file:${sanitizeKey(lf.path)}`
|
|
247
254
|
const cardReviewed = ctx.state?.reviewed[cardReviewedId] === true
|
|
248
|
-
const collapsed =
|
|
255
|
+
const collapsed =
|
|
256
|
+
cardReviewed || (lf.collapsed === true && lf.annotations.length === 0 && ctx.keepOpen !== true)
|
|
249
257
|
const isFirst = !ctx.firstCardFor.has(key)
|
|
250
258
|
ctx.firstCardFor.add(key)
|
|
251
259
|
const id = isFirst ? fileAnchorId(key) : `${fileAnchorId(key)}-${layer.key}`
|
|
252
260
|
const pills = entry
|
|
253
261
|
? `<span class="pill add">+${entry.additions}</span><span class="pill del">−${entry.deletions}</span>`
|
|
254
262
|
: ''
|
|
255
|
-
const status =
|
|
263
|
+
const status =
|
|
264
|
+
entry && entry.status !== 'modified' ? `<span class="status">${esc(entry.status)}</span>` : ''
|
|
256
265
|
const path =
|
|
257
266
|
entry?.oldPath !== undefined
|
|
258
267
|
? `<span class="old">${esc(entry.oldPath)} → </span>${esc(lf.path)}`
|
|
@@ -272,7 +281,7 @@ export function renderFileCard(lf, entry, layer, ctx) {
|
|
|
272
281
|
}
|
|
273
282
|
|
|
274
283
|
/**
|
|
275
|
-
* "2 more
|
|
284
|
+
* "2 more chunks in layer 4 · title" when a file's other hunks live in other layers.
|
|
276
285
|
* @param {LayerFile} lf
|
|
277
286
|
* @param {FileEntry | undefined} entry
|
|
278
287
|
* @param {Layer} layer
|
|
@@ -301,7 +310,7 @@ export function elsewhereHtml(lf, entry, layer, hunkIndex) {
|
|
|
301
310
|
}
|
|
302
311
|
const parts = [...others.values()].map(
|
|
303
312
|
o =>
|
|
304
|
-
`${o.n} more ${o.n === 1 ? '
|
|
313
|
+
`${o.n} more ${o.n === 1 ? 'chunk' : 'chunks'} in <a href="#${esc(layerAnchorId(o.layer.key))}">${o.layer.kind === 'other' ? esc(o.layer.title) : `layer ${o.index + 1} · ${esc(o.layer.title)}`}</a>`
|
|
305
314
|
)
|
|
306
315
|
return `<div class="more-hunks">${parts.join(' · ')}</div>`
|
|
307
316
|
}
|
|
@@ -411,7 +420,8 @@ export function hydrateFileCard(card, ctx, opts = {}) {
|
|
|
411
420
|
const entry = ctx.files.find(f => f.key === key)
|
|
412
421
|
const patch = ctx.patches?.[key]
|
|
413
422
|
if (!entry || patch === undefined) {
|
|
414
|
-
host.innerHTML =
|
|
423
|
+
host.innerHTML =
|
|
424
|
+
'<div class="unavailable">Diff not available locally. Fetch the PR head and reload.</div>'
|
|
415
425
|
return { rendered: false, deferred: false, placed: 0, missed: 0 }
|
|
416
426
|
}
|
|
417
427
|
const lines = patchLineCount(patch)
|
|
@@ -432,10 +442,10 @@ export function hydrateFileCard(card, ctx, opts = {}) {
|
|
|
432
442
|
async () => {
|
|
433
443
|
hydrateFileCard(card, ctx, { force: true })
|
|
434
444
|
// The host element now holds a drawn diff, so a later link into it leaves it alone.
|
|
435
|
-
const
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
445
|
+
const fileHost = card.closest('pr-file')
|
|
446
|
+
if (fileHost instanceof PrFileElement) {
|
|
447
|
+
fileHost.rendered = true
|
|
448
|
+
fileHost.deferred = false
|
|
439
449
|
}
|
|
440
450
|
},
|
|
441
451
|
{ pendingLabel: 'drawing…' }
|
|
@@ -450,7 +460,9 @@ export function hydrateFileCard(card, ctx, opts = {}) {
|
|
|
450
460
|
const layer = ctx.artifact.layers.find(l => l.id === layerId)
|
|
451
461
|
const lf = layer?.files.find(f => f.path === entry.path)
|
|
452
462
|
const annotations = lf?.annotations ?? []
|
|
453
|
-
const points = ctx.artifact.points.filter(
|
|
463
|
+
const points = ctx.artifact.points.filter(
|
|
464
|
+
p => p.path === entry.path && hunkIds.has(hunkIdForPoint(p, entry))
|
|
465
|
+
)
|
|
454
466
|
const threads = threadsForHunks(ctx.comments, entry, hunkIds)
|
|
455
467
|
const { placed, missed } = applyDecorations(card, key, {
|
|
456
468
|
annotations,
|
package/static/js/links.js
CHANGED
|
@@ -99,14 +99,20 @@ export function resolveLink(link, targets) {
|
|
|
99
99
|
return { ok: false, message: `${link.path} is not in the diff` }
|
|
100
100
|
}
|
|
101
101
|
if (link.kind === 'hunk' && (link.n < 1 || link.n > file.hunks.length)) {
|
|
102
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} chunks)`,
|
|
105
|
+
}
|
|
103
106
|
}
|
|
104
107
|
if (link.kind === 'line') {
|
|
105
108
|
const start = hunkForLine(file.hunks, link.side, link.start)
|
|
106
109
|
const end = hunkForLine(file.hunks, link.side, link.end)
|
|
107
110
|
if (start === null || start !== end) {
|
|
108
111
|
const range = link.end === link.start ? `${link.start}` : `${link.start}-${link.end}`
|
|
109
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
message: `${link.path}:${range} (${link.side}) is not inside one chunk of the diff (${hunkLineRanges(file.hunks, link.side)})`,
|
|
115
|
+
}
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
118
|
return { ok: true }
|
|
@@ -143,7 +149,7 @@ export function linkLabel(link) {
|
|
|
143
149
|
case 'file':
|
|
144
150
|
return link.path
|
|
145
151
|
case 'hunk':
|
|
146
|
-
return `${link.path}
|
|
152
|
+
return `${link.path} chunk ${link.n}`
|
|
147
153
|
case 'line':
|
|
148
154
|
return `${link.path}:${link.start}${link.end !== link.start ? `-${link.end}` : ''}${link.side === 'old' ? ' (old)' : ''}`
|
|
149
155
|
}
|
package/static/js/markdown.js
CHANGED
|
@@ -147,6 +147,28 @@ function decorateLinks(root) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* GitHub images can require a signed-in browser session. Link to them so the browser
|
|
152
|
+
* can open them on their own origin, where that session is available.
|
|
153
|
+
* @param {HTMLImageElement} img
|
|
154
|
+
* @param {string} src
|
|
155
|
+
*/
|
|
156
|
+
function linkGitHubImage(img, src) {
|
|
157
|
+
const link = document.createElement('a')
|
|
158
|
+
link.href = src
|
|
159
|
+
const alt = img.alt.trim()
|
|
160
|
+
link.textContent = alt ? `View image on GitHub: ${alt}` : 'View image on GitHub'
|
|
161
|
+
if (img.title) link.title = img.title
|
|
162
|
+
const enclosingLink = img.closest('a')
|
|
163
|
+
if (enclosingLink) {
|
|
164
|
+
// Keep the original destination without creating nested links.
|
|
165
|
+
img.replaceWith(document.createTextNode(alt || 'Image'))
|
|
166
|
+
enclosingLink.after(document.createTextNode(' '), link)
|
|
167
|
+
} else {
|
|
168
|
+
img.replaceWith(link)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
/**
|
|
151
173
|
* One markdown text, without mermaid blocks.
|
|
152
174
|
* @param {string} src markdown
|
|
@@ -172,8 +194,13 @@ function renderProse(src, opts) {
|
|
|
172
194
|
if (!ALLOWED_TAGS.includes(element.tagName.toLowerCase())) element.remove()
|
|
173
195
|
}
|
|
174
196
|
for (const img of root.querySelectorAll('img')) {
|
|
175
|
-
|
|
197
|
+
const imageSrc = img.getAttribute('src') ?? ''
|
|
198
|
+
if (!/^https:\/\//i.test(imageSrc)) {
|
|
176
199
|
img.remove()
|
|
200
|
+
} else if (
|
|
201
|
+
/^https:\/\/(?:github\.com|(?:[a-z0-9-]+\.)*githubusercontent\.com)(?::443)?\//i.test(imageSrc)
|
|
202
|
+
) {
|
|
203
|
+
linkGitHubImage(img, imageSrc)
|
|
177
204
|
} else {
|
|
178
205
|
img.loading = 'lazy'
|
|
179
206
|
img.referrerPolicy = 'no-referrer'
|
package/static/js/nav.js
CHANGED
|
@@ -46,7 +46,8 @@ export function buildNavOrder(artifact) {
|
|
|
46
46
|
* @returns {NavItem | null}
|
|
47
47
|
*/
|
|
48
48
|
function step(order, currentId, kind, direction) {
|
|
49
|
-
const from =
|
|
49
|
+
const from =
|
|
50
|
+
currentId === null ? (direction === 1 ? -1 : order.length) : order.findIndex(i => i.id === currentId)
|
|
50
51
|
const start = from === -1 && currentId !== null ? (direction === 1 ? -1 : order.length) : from
|
|
51
52
|
for (let i = start + direction; i >= 0 && i < order.length; i += direction) {
|
|
52
53
|
const item = order[i]
|
|
@@ -87,5 +88,7 @@ export function layerOf(order, id) {
|
|
|
87
88
|
if (!item || item.kind === 'overview') {
|
|
88
89
|
return null
|
|
89
90
|
}
|
|
90
|
-
return item.kind === 'layer'
|
|
91
|
+
return item.kind === 'layer'
|
|
92
|
+
? item
|
|
93
|
+
: (order.find(i => i.kind === 'layer' && i.layerId === item.layerId) ?? null)
|
|
91
94
|
}
|
package/static/js/overview.js
CHANGED
|
@@ -2,11 +2,32 @@
|
|
|
2
2
|
/** @typedef {import('./contract-types.js').PrBundle} PrBundle */
|
|
3
3
|
/** @typedef {import('./contract-types.js').IssueComment} IssueComment */
|
|
4
4
|
|
|
5
|
-
import { commentHtml } from './diff-decorations.js'
|
|
6
5
|
import { viewCommentHtml } from './comment-link.js'
|
|
7
6
|
import { detailsSummaryHtml, esc, avatarHtml, timeAgo } from './dom.js'
|
|
8
7
|
import { renderMarkdown } from './markdown.js'
|
|
9
8
|
import { dismissedListHtml, postedUrls, sevsumHtml } from './points.js'
|
|
9
|
+
import { buildThreads } from './threads.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {PrBundle['comments']['reviewComments']} comments
|
|
13
|
+
* @param {Date} now
|
|
14
|
+
*/
|
|
15
|
+
function outdatedCommentsHtml(comments, now) {
|
|
16
|
+
const threads = [...buildThreads(comments).outdated.values()].flat()
|
|
17
|
+
if (!threads.length) return ''
|
|
18
|
+
const count = threads.reduce((total, thread) => total + 1 + thread.replies.length, 0)
|
|
19
|
+
const entries = threads
|
|
20
|
+
.map(thread => {
|
|
21
|
+
const line = thread.root.originalLine
|
|
22
|
+
return (
|
|
23
|
+
`<div class="body"><p class="muted small">${esc(thread.path)}${line === null ? '' : `:${line} (original)`}${thread.resolved ? ' · resolved' : ''}</p>` +
|
|
24
|
+
[thread.root, ...thread.replies].map(comment => issueCommentHtml(comment, now)).join('') +
|
|
25
|
+
'</div>'
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
.join('')
|
|
29
|
+
return `<details class="outdated-comments">${detailsSummaryHtml(`<span>Outdated comments · ${count}</span>`, 'Toggle outdated comments')}${entries}</details>`
|
|
30
|
+
}
|
|
10
31
|
|
|
11
32
|
/**
|
|
12
33
|
* "What changes": the PR-wide summary as one prose block.
|
|
@@ -61,7 +82,12 @@ export function conversationHtml(comments, now) {
|
|
|
61
82
|
*/
|
|
62
83
|
export function renderOverview(bundle, ctx) {
|
|
63
84
|
const artifact = bundle.artifact
|
|
64
|
-
const
|
|
85
|
+
const reviews = (bundle.comments.reviews ?? []).filter(
|
|
86
|
+
review => review.state !== 'COMMENTED' || review.body.trim().length > 0
|
|
87
|
+
)
|
|
88
|
+
const active = artifact
|
|
89
|
+
? artifact.points.filter(p => bundle.state.dismissed[p.fingerprint] === undefined)
|
|
90
|
+
: []
|
|
65
91
|
const summary = artifact ? summaryHtml(artifact.summary, ctx.paths) : ''
|
|
66
92
|
const description = bundle.pr.body.trim()
|
|
67
93
|
? `<details class="pr-desc">${detailsSummaryHtml('<span>PR description (from GitHub)</span>', 'Toggle PR description')}<div class="body prose">${renderMarkdown(bundle.pr.body, { paths: ctx.paths, github: true })}</div></details>`
|
|
@@ -72,8 +98,10 @@ export function renderOverview(bundle, ctx) {
|
|
|
72
98
|
`<div class="body">${summary}</div>` +
|
|
73
99
|
description +
|
|
74
100
|
conversationHtml(bundle.comments.issueComments, ctx.now) +
|
|
75
|
-
(
|
|
76
|
-
|
|
101
|
+
(reviews.length
|
|
102
|
+
? `<details class="review-history">${detailsSummaryHtml(`<span>Review history · ${reviews.length}</span>`, 'Toggle review history')}${reviews.map(review => `<div class="body review-entry"><span class="pill">${esc(review.state.toLowerCase().replaceAll('_', ' '))}</span>${issueCommentHtml(review, ctx.now)}</div>`).join('')}</details>`
|
|
103
|
+
: '') +
|
|
104
|
+
outdatedCommentsHtml(bundle.comments.reviewComments, ctx.now) +
|
|
77
105
|
(artifact
|
|
78
106
|
? dismissedListHtml(artifact.points, bundle.state, {
|
|
79
107
|
paths: ctx.paths,
|
package/static/js/points.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { askButtonHtml } from './ask.js'
|
|
6
6
|
import { viewCommentHtml } from './comment-link.js'
|
|
7
7
|
import { esc } from './dom.js'
|
|
8
|
+
import { postToLabel } from './host.js'
|
|
8
9
|
import { layerAnchorId, pointAnchorId } from './keys.js'
|
|
9
10
|
import { renderMarkdown } from './markdown.js'
|
|
10
11
|
|
|
@@ -80,7 +81,7 @@ export function pointCommandsHtml(p, opts = {}) {
|
|
|
80
81
|
'<span class="tbtns">' +
|
|
81
82
|
`<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
|
|
82
83
|
(opts.postedUrl === undefined
|
|
83
|
-
? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post
|
|
84
|
+
? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>`
|
|
84
85
|
: viewCommentHtml(opts.postedUrl)) +
|
|
85
86
|
askButtonHtml(pointContext(p)) +
|
|
86
87
|
toggle +
|
|
@@ -190,7 +191,8 @@ export function applyDismissed(root, points, state, ctx) {
|
|
|
190
191
|
}
|
|
191
192
|
const host = root.querySelector('.dismissed-list')
|
|
192
193
|
if (host !== null) {
|
|
193
|
-
const expanded =
|
|
194
|
+
const expanded =
|
|
195
|
+
host.querySelector('[data-act="show-dismissed"]')?.getAttribute('aria-expanded') === 'true'
|
|
194
196
|
const template = document.createElement('template')
|
|
195
197
|
template.innerHTML = dismissedListHtml(points, state, ctx, expanded)
|
|
196
198
|
const next = template.content.firstElementChild
|
|
@@ -219,7 +221,7 @@ export function pointRowHtml(p, ctx) {
|
|
|
219
221
|
const dismissed = ctx.state?.dismissed[p.fingerprint] !== undefined
|
|
220
222
|
const posted = postedFor(p, ctx)
|
|
221
223
|
return (
|
|
222
|
-
`<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="
|
|
224
|
+
`<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="code x" colspan="4">` +
|
|
223
225
|
`<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
|
|
224
226
|
`<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
|
|
225
227
|
`${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
|
package/static/js/progress.js
CHANGED
|
@@ -45,5 +45,6 @@ export function progressSummary(artifact, state) {
|
|
|
45
45
|
* @param {PrState} state
|
|
46
46
|
*/
|
|
47
47
|
export function filesReviewed(layer, state) {
|
|
48
|
-
return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true)
|
|
48
|
+
return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true)
|
|
49
|
+
.length
|
|
49
50
|
}
|
|
@@ -63,7 +63,10 @@ export function parseProposedComment(source, targets) {
|
|
|
63
63
|
return { reason: 'side must be "new" or "old"' }
|
|
64
64
|
}
|
|
65
65
|
const side = sideRaw === 'old' ? 'old' : 'new'
|
|
66
|
-
if (
|
|
66
|
+
if (
|
|
67
|
+
startRaw !== undefined &&
|
|
68
|
+
(typeof startRaw !== 'number' || !Number.isInteger(startRaw) || startRaw <= 0)
|
|
69
|
+
) {
|
|
67
70
|
return { reason: 'startLine must be a line number' }
|
|
68
71
|
}
|
|
69
72
|
const startLine = typeof startRaw === 'number' ? startRaw : undefined
|
|
@@ -18,7 +18,8 @@ export const ASK_SOMETHING_ELSE = 'ask something else…'
|
|
|
18
18
|
export function quickMenuHtml() {
|
|
19
19
|
const items = [...QUICK_QUESTIONS, ASK_SOMETHING_ELSE]
|
|
20
20
|
.map(
|
|
21
|
-
(q, i) =>
|
|
21
|
+
(q, i) =>
|
|
22
|
+
`<button class="qq-item" type="button" role="menuitem" tabindex="-1" data-qq="${i}">${esc(q)}</button>`
|
|
22
23
|
)
|
|
23
24
|
.join('')
|
|
24
25
|
return `<div class="qq-menu" id="${QQ_MENU_ID}" role="menu" aria-label="Quick questions" hidden>${items}</div>`
|
package/static/js/regenerate.js
CHANGED
|
@@ -65,5 +65,8 @@ export function canvasChanged(current, next) {
|
|
|
65
65
|
if (current.status !== 'ready' || !current.artifact || !current.canvas) {
|
|
66
66
|
return true
|
|
67
67
|
}
|
|
68
|
-
return
|
|
68
|
+
return (
|
|
69
|
+
next.canvas.headSha !== current.canvas.headSha ||
|
|
70
|
+
next.artifact.generatedAt !== current.artifact.generatedAt
|
|
71
|
+
)
|
|
69
72
|
}
|
|
@@ -24,12 +24,13 @@ import { sanitizeKey } from './keys.js'
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* @typedef {{
|
|
27
|
-
* prNumber:
|
|
27
|
+
* prNumber: import('./contract-types.js').ReviewKey,
|
|
28
28
|
* artifact: ReviewArtifact,
|
|
29
29
|
* files: ReadonlyArray<FileEntry>,
|
|
30
30
|
* state: PrState,
|
|
31
31
|
* capabilities: Capabilities,
|
|
32
32
|
* headSha: string,
|
|
33
|
+
* canvasSha?: string,
|
|
33
34
|
* api?: Partial<SessionApi>,
|
|
34
35
|
* onState?: (state: PrState) => void,
|
|
35
36
|
* }} SessionOptions
|
|
@@ -193,7 +194,11 @@ export function createReviewSession(options) {
|
|
|
193
194
|
return change(
|
|
194
195
|
current => withEntry(current, 'reviewed', id, reviewed ? true : undefined),
|
|
195
196
|
current => withEntry(current, 'reviewed', id, before),
|
|
196
|
-
() =>
|
|
197
|
+
() =>
|
|
198
|
+
api.putReviewed(options.prNumber, id, reviewed, {
|
|
199
|
+
headSha: options.headSha,
|
|
200
|
+
...(options.canvasSha === undefined ? {} : { canvasSha: options.canvasSha }),
|
|
201
|
+
})
|
|
197
202
|
)
|
|
198
203
|
},
|
|
199
204
|
/**
|
package/static/js/settings.js
CHANGED
|
@@ -65,12 +65,13 @@ export function settingsDialogHtml(data, agents) {
|
|
|
65
65
|
`<p class="muted small mono">${esc(data.file)}</p>` +
|
|
66
66
|
'<div class="panel-ro"><h3>Project config (read-only)</h3>' +
|
|
67
67
|
`<ul class="plain"><li>chat enabled: ${project.chatEnabled ? 'yes' : 'no'}</li>` +
|
|
68
|
+
`<li>canvas kept for an identical diff: ${project.keepForIdenticalDiff ? 'yes' : 'no'}</li>` +
|
|
68
69
|
`<li>rulebook: ${esc(project.rulebook ?? 'none')}</li>` +
|
|
69
70
|
`<li>configured layer suggestions: ${project.layers}</li>` +
|
|
70
71
|
`<li>high-risk patterns: ${project.highRisk}</li>` +
|
|
71
72
|
`<li>max repair rounds: ${project.maxRepairRounds}</li>` +
|
|
72
73
|
`<li>inline diff max lines: ${project.inlineDiffMaxLines}</li>` +
|
|
73
|
-
`<li>small change set: ${project.smallPrHunks}
|
|
74
|
+
`<li>small change set: ${project.smallPrHunks} chunks</li></ul>` +
|
|
74
75
|
`<p class="muted small mono">${esc(project.file ?? 'built-in defaults (no pr-review.config.yml)')}</p></div>` +
|
|
75
76
|
'<p class="probe-result" role="status"></p>' +
|
|
76
77
|
'<div class="dialog-actions">' +
|
package/static/js/signoff.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
5
5
|
/** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
|
|
6
6
|
/** @typedef {import('./contract-types.js').ReviewBodyResponse} ReviewBodyResponse */
|
|
7
|
-
import { previewControlsHtml, setDisabledReason } from './composer.js'
|
|
7
|
+
import { previewControlsHtml, setDisabledReason, setMarkdownPreview } from './composer.js'
|
|
8
8
|
import { esc } from './dom.js'
|
|
9
|
+
import { hostLabel } from './host.js'
|
|
9
10
|
import { layerProgress } from './progress.js'
|
|
10
11
|
|
|
11
12
|
export const SIGNOFF_DIALOG_ID = 'signoff-dialog'
|
|
@@ -20,7 +21,9 @@ export const LOADING_REASON = 'the review body is still loading'
|
|
|
20
21
|
* @returns {string | null}
|
|
21
22
|
*/
|
|
22
23
|
export function approveBlockedReason(artifact, state) {
|
|
23
|
-
const [first, ...rest] = artifact.layers.filter(
|
|
24
|
+
const [first, ...rest] = artifact.layers.filter(
|
|
25
|
+
l => l.kind !== 'other' && layerProgress(l, state) !== 'done'
|
|
26
|
+
)
|
|
24
27
|
if (first === undefined) {
|
|
25
28
|
return null
|
|
26
29
|
}
|
|
@@ -33,7 +36,7 @@ export function approveBlockedReason(artifact, state) {
|
|
|
33
36
|
* @param {'APPROVE' | 'REQUEST_CHANGES'} event
|
|
34
37
|
*/
|
|
35
38
|
export function signoffTitle(event) {
|
|
36
|
-
return event === 'APPROVE' ?
|
|
39
|
+
return event === 'APPROVE' ? `Approve on ${hostLabel()}` : `Request changes on ${hostLabel()}`
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
|
@@ -44,7 +47,7 @@ export function signoffDialogHtml(opts) {
|
|
|
44
47
|
return (
|
|
45
48
|
`<dialog id="${SIGNOFF_DIALOG_ID}" class="signoff-dialog" aria-labelledby="signoff-h">` +
|
|
46
49
|
`<h2 id="signoff-h">${esc(signoffTitle(opts.event))}</h2>` +
|
|
47
|
-
|
|
50
|
+
`<p class="hint">This posts a review on ${esc(hostLabel())} as you. Edit the body first if you want to.</p>` +
|
|
48
51
|
'<p class="signoff-target muted mono"></p>' +
|
|
49
52
|
'<label class="sr" for="signoff-body">Review body</label>' +
|
|
50
53
|
previewControlsHtml() +
|
|
@@ -75,7 +78,7 @@ export function openSignoffDialog(root, opts) {
|
|
|
75
78
|
if (!(dialog instanceof HTMLDialogElement)) {
|
|
76
79
|
throw new Error('sign-off dialog did not render')
|
|
77
80
|
}
|
|
78
|
-
dialog
|
|
81
|
+
setMarkdownPreview(dialog, false)
|
|
79
82
|
dialog.setAttribute('data-event', opts.event)
|
|
80
83
|
const heading = dialog.querySelector('#signoff-h')
|
|
81
84
|
if (heading !== null) {
|
|
@@ -129,7 +132,7 @@ export function showSignoffResult(dialog, review) {
|
|
|
129
132
|
const result = dialog.querySelector('.signoff-result')
|
|
130
133
|
if (result !== null) {
|
|
131
134
|
result.textContent = 'Posted · '
|
|
132
|
-
result.append(externalLink(review.url,
|
|
135
|
+
result.append(externalLink(review.url, `see it on ${hostLabel()}`))
|
|
133
136
|
}
|
|
134
137
|
return result
|
|
135
138
|
}
|