@goodandready/dsh-github-ops 0.6.13 → 0.6.15

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.15
4
+
5
+ ### Features
6
+ - **Inline PR Review Workspace in Web UI (#116)**: Added interactive side-by-side / hunk diff viewer in the Web UI repository panel (`tab === 'pulls'`) with line number tracking (`oldLine` / `newLine`), click-to-comment inline review form, one-click ```suggestion``` snippet generator, live review thread discussion viewer with reply inputs, conversation resolution toggling (`resolve_thread`), and full pull request review submissions (Approve, Comment, Request Changes) backed by dedicated `/dsh-github-ops/pr-review` endpoint.
7
+
3
8
  ## 0.6.13
4
9
 
5
10
  ### Features
package/lib/client.js CHANGED
@@ -20,6 +20,7 @@ window.__ModuleLoader__.load({
20
20
  const SCM_PATH = '/dsh-github-ops/scm'
21
21
  const MIRROR_PATH = '/dsh-github-ops/mirror'
22
22
  const PANEL_PATH = '/dsh-github-ops/panel'
23
+ const PR_REVIEW_PATH = '/dsh-github-ops/pr-review'
23
24
 
24
25
  const fetchWithTimeout = (url, options = {}, timeoutMs = 15000) => {
25
26
  const signal = (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
@@ -132,6 +133,26 @@ window.__ModuleLoader__.load({
132
133
  panelSecurityDisabled: 'Security features are disabled or token lacks security scope.',
133
134
  panelChangelog: 'Changelog',
134
135
  panelChangelogEmpty: 'No conventional changelog entries found.',
136
+ panelPrReviewBtn: 'Review',
137
+ panelPrBack: '← Pull requests',
138
+ panelPrFilesChanged: 'Files changed',
139
+ panelPrAddComment: 'Add comment',
140
+ panelPrInsertSuggestion: 'Insert suggestion',
141
+ panelPrSubmitReview: 'Submit review',
142
+ panelPrApprove: 'Approve',
143
+ panelPrComment: 'Comment',
144
+ panelPrRequestChanges: 'Request changes',
145
+ panelPrReviewBody: 'Review summary…',
146
+ panelPrReply: 'Reply',
147
+ panelPrReplyPlaceholder: 'Write a reply…',
148
+ panelPrCommentPlaceholder: 'Leave a comment…',
149
+ panelPrResolve: 'Resolve conversation',
150
+ panelPrUnresolve: 'Unresolve conversation',
151
+ panelPrResolved: 'Resolved',
152
+ panelPrUnresolved: 'Unresolved',
153
+ panelPrApplySuggestion: 'Apply suggestion',
154
+ panelPrEmptyDiff: 'No diff hunks available for this file.',
155
+ panelPrRefresh: 'Refresh',
135
156
  panelCopyMarkdown: 'Copy Markdown',
136
157
  panelDiagnose: 'Diagnose',
137
158
  panelDiagnosing: 'Diagnosing…',
@@ -262,6 +283,26 @@ window.__ModuleLoader__.load({
262
283
  panelSecurityDisabled: '安全功能已禁用或令牌缺少安全权限。',
263
284
  panelChangelog: '更新日志',
264
285
  panelChangelogEmpty: '未找到常规提交更新日志记录。',
286
+ panelPrReviewBtn: '评审',
287
+ panelPrBack: '← 返回 PR 列表',
288
+ panelPrFilesChanged: '变更文件',
289
+ panelPrAddComment: '添加行评论',
290
+ panelPrInsertSuggestion: '插入修改建议',
291
+ panelPrSubmitReview: '提交评审',
292
+ panelPrApprove: '批准',
293
+ panelPrComment: '评论',
294
+ panelPrRequestChanges: '请求修改',
295
+ panelPrReviewBody: '评审总结…',
296
+ panelPrReply: '回复',
297
+ panelPrReplyPlaceholder: '填写回复…',
298
+ panelPrCommentPlaceholder: '填写评论…',
299
+ panelPrResolve: '解决讨论',
300
+ panelPrUnresolve: '重新打开讨论',
301
+ panelPrResolved: '已解决',
302
+ panelPrUnresolved: '未解决',
303
+ panelPrApplySuggestion: '应用建议',
304
+ panelPrEmptyDiff: '该文件无可显示的差异代码块。',
305
+ panelPrRefresh: '刷新',
265
306
  panelCopyMarkdown: '复制 Markdown',
266
307
  panelDiagnose: '诊断',
267
308
  panelDiagnosing: '诊断中…',
@@ -873,6 +914,7 @@ window.__ModuleLoader__.load({
873
914
  const [tree, setTree] = React.useState({ phase: 'idle', path: '', entries: [] })
874
915
  const [file, setFile] = React.useState(null)
875
916
  const [items, setItems] = React.useState({ phase: 'idle', list: [] })
917
+ const [selectedPr, setSelectedPr] = React.useState(null)
876
918
  const [error, setError] = React.useState('')
877
919
  const [loading, setLoading] = React.useState(false)
878
920
  const [securityData, setSecurityData] = React.useState({ phase: 'idle', data: null })
@@ -1035,6 +1077,12 @@ window.__ModuleLoader__.load({
1035
1077
  style: { marginLeft: '8px' },
1036
1078
  onClick: (e) => { e.stopPropagation(); loadDiagnostics(item.id) },
1037
1079
  }, diagnoseLoadingId === item.id ? t('panelDiagnosing') : t('panelDiagnose')) : null,
1080
+ tab === 'pulls' ? React.createElement('button', {
1081
+ type: 'button',
1082
+ className: 'gho-diagnose-btn',
1083
+ style: { marginLeft: '8px' },
1084
+ onClick: (e) => { e.stopPropagation(); setSelectedPr(item.number) },
1085
+ }, t('panelPrReviewBtn')) : null,
1038
1086
  item.url || item.html_url ? React.createElement('a', { className: 'gho-link', href: item.url || item.html_url, target: '_blank', rel: 'noreferrer' }, '↗') : null,
1039
1087
  ),
1040
1088
  diagnostics && diagnostics.runId === item.id ? React.createElement('div', { style: { padding: '8px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', margin: '4px 0 8px', background: 'var(--dsw-alias-bg-layer-2)' } },
@@ -1198,6 +1246,24 @@ window.__ModuleLoader__.load({
1198
1246
  )
1199
1247
  })()
1200
1248
 
1249
+ if (tab === 'pulls' && selectedPr) {
1250
+ return React.createElement('div', { className: 'gho-panel' },
1251
+ header,
1252
+ React.createElement('div', { className: 'gho-panel-tabs' },
1253
+ tabButton('tree', 'Code'),
1254
+ tabButton('issues', t('panelIssues')),
1255
+ tabButton('pulls', t('panelPulls')),
1256
+ tabButton('runs', t('panelRuns')),
1257
+ tabButton('security', t('panelSecurity')),
1258
+ tabButton('changelog', t('panelChangelog')),
1259
+ tabButton('inbox', t('panelInbox')),
1260
+ tabButton('matrix', t('panelMatrix')),
1261
+ React.createElement('button', { type: 'button', className: 'gho-panel-tab', onClick: () => { setRepo(''); setFile(null); setSelectedPr(null) } }, t('panelRepo')),
1262
+ ),
1263
+ React.createElement(PRReviewWorkspace, { repo, number: selectedPr, t, onBack: () => setSelectedPr(null) }),
1264
+ )
1265
+ }
1266
+
1201
1267
  return React.createElement('div', { className: 'gho-panel' },
1202
1268
  header,
1203
1269
  React.createElement('div', { className: 'gho-panel-tabs' },
@@ -1332,10 +1398,280 @@ window.__ModuleLoader__.load({
1332
1398
  '.gho-toast-success{border-left:4px solid #22c55e}',
1333
1399
  '.gho-toast-warn{border-left:4px solid #ef4444}',
1334
1400
  '.gho-toast-info{border-left:4px solid #6366f1}',
1401
+ '.gho-pr-workspace{display:flex;flex-direction:column;gap:10px;padding:8px 0}',
1402
+ '.gho-pr-header{display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}',
1403
+ '.gho-pr-file{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;margin-bottom:12px;overflow:hidden}',
1404
+ '.gho-pr-file-head{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12px;font-weight:600}',
1405
+ '.gho-pr-line-wrap{display:flex;flex-direction:column}',
1406
+ '.gho-pr-gutter{width:60px;flex-shrink:0;display:flex;justify-content:space-between;padding:0 6px;user-select:none;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2);border-right:1px solid var(--dsw-alias-border-l2);cursor:pointer}',
1407
+ '.gho-pr-gutter:hover{background:rgba(99,102,241,0.15);color:#818cf8}',
1408
+ '.gho-pr-thread{margin:6px 12px;padding:8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);font-size:12px}',
1409
+ '.gho-pr-thread-head{display:flex;justify-content:space-between;margin-bottom:6px;font-weight:600}',
1410
+ '.gho-pr-comment{margin:4px 0;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}',
1411
+ '.gho-pr-comment:last-child{border-bottom:0}',
1412
+ '.gho-pr-inline-box{margin:6px 12px;padding:8px;border-radius:6px;border:1px solid rgba(99,102,241,0.3);background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:6px}',
1335
1413
  ].join('\n')
1336
1414
  document.head.appendChild(style)
1337
1415
  }
1338
1416
 
1417
+ function PRReviewWorkspace(props) {
1418
+ const repo = props.repo
1419
+ const number = props.number
1420
+ const t = props.t || ((key) => key)
1421
+ const onBack = props.onBack
1422
+
1423
+ const [reviewData, setReviewData] = React.useState({ phase: 'loading', data: null, error: '' })
1424
+ const [activeComment, setActiveComment] = React.useState(null)
1425
+ const [replyState, setReplyState] = React.useState({ threadId: null, commentId: null, body: '' })
1426
+ const [reviewSubmit, setReviewSubmit] = React.useState({ open: false, body: '', event: 'COMMENT' })
1427
+ const [busy, setBusy] = React.useState('')
1428
+ const [actionError, setActionError] = React.useState('')
1429
+
1430
+ const loadData = React.useCallback(() => {
1431
+ setReviewData((prev) => ({ ...prev, phase: 'loading', error: '' }))
1432
+ fetchWithTimeout(`${PR_REVIEW_PATH}?repo=${encodeURIComponent(repo)}&number=${number}`, { headers: { accept: 'application/json' } })
1433
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
1434
+ .then((payload) => {
1435
+ if (payload.configured === false) {
1436
+ setReviewData({ phase: 'error', data: null, error: payload.guidance || t('panelNoAccess') })
1437
+ return
1438
+ }
1439
+ setReviewData({ phase: 'ready', data: payload, error: '' })
1440
+ })
1441
+ .catch((err) => setReviewData({ phase: 'error', data: null, error: String((err && err.message) || err) }))
1442
+ }, [repo, number, t])
1443
+
1444
+ React.useEffect(() => {
1445
+ loadData()
1446
+ }, [loadData])
1447
+
1448
+ const sendAction = (action, extra) => {
1449
+ setBusy(action)
1450
+ setActionError('')
1451
+ return fetchWithTimeout(PR_REVIEW_PATH, {
1452
+ method: 'POST',
1453
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1454
+ body: JSON.stringify({ action, repo, number, ...(extra || {}) }),
1455
+ })
1456
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
1457
+ .then((payload) => {
1458
+ setBusy('')
1459
+ if (!payload.ok) throw new Error(payload.error || 'Action failed')
1460
+ loadData()
1461
+ return payload
1462
+ })
1463
+ .catch((err) => {
1464
+ setBusy('')
1465
+ setActionError(String((err && err.message) || err))
1466
+ })
1467
+ }
1468
+
1469
+ if (reviewData.phase === 'loading') {
1470
+ return React.createElement('div', { className: 'gho-panel-muted', style: { padding: '16px' } }, t('panelLoading'))
1471
+ }
1472
+ if (reviewData.phase === 'error' || !reviewData.data) {
1473
+ return React.createElement('div', { className: 'gho-panel-muted', style: { padding: '16px' } },
1474
+ React.createElement('button', { type: 'button', className: 'gho-link', onClick: onBack }, t('panelPrBack')),
1475
+ React.createElement('div', { className: 'gho-scm-error', style: { marginTop: '8px' } }, reviewData.error || t('panelEmpty')),
1476
+ )
1477
+ }
1478
+
1479
+ const pull = reviewData.data.pull || {}
1480
+ const files = reviewData.data.files || []
1481
+ const threads = reviewData.data.threads || []
1482
+
1483
+ const header = React.createElement('div', { className: 'gho-pr-header' },
1484
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1485
+ React.createElement('button', { type: 'button', className: 'gho-link', style: { margin: 0 }, onClick: onBack }, t('panelPrBack')),
1486
+ React.createElement('div', { style: { display: 'flex', gap: '6px' } },
1487
+ React.createElement('button', { type: 'button', className: 'gho-diagnose-btn', disabled: Boolean(busy), onClick: loadData }, t('panelPrRefresh')),
1488
+ React.createElement('button', {
1489
+ type: 'button',
1490
+ className: 'gho-diagnose-btn',
1491
+ style: { background: 'var(--dsw-alias-color-primary, #1f6feb)', color: '#fff', border: 0 },
1492
+ onClick: () => setReviewSubmit((s) => ({ ...s, open: !s.open })),
1493
+ }, t('panelPrSubmitReview')),
1494
+ ),
1495
+ ),
1496
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'baseline' } },
1497
+ React.createElement('strong', { style: { fontSize: '14px' } }, `#${pull.number} ${pull.title}`),
1498
+ React.createElement('span', { className: `gho-badge gho-badge-${pull.state === 'open' ? 'active' : (pull.merged ? 'healthy' : 'failing')}` }, pull.state),
1499
+ ),
1500
+ React.createElement('div', { className: 'gho-panel-muted', style: { fontSize: '11px', display: 'flex', gap: '10px' } },
1501
+ React.createElement('span', null, `${pull.base || 'main'} ← ${pull.head || ''}`),
1502
+ React.createElement('span', null, `@${pull.author || 'unknown'}`),
1503
+ pull.changedFiles ? React.createElement('span', null, `${pull.changedFiles} files (+${pull.additions || 0} -${pull.deletions || 0})`) : null,
1504
+ ),
1505
+ actionError ? React.createElement('div', { className: 'gho-scm-meta gho-scm-error' }, actionError) : null,
1506
+ )
1507
+
1508
+ const reviewSubmitBox = reviewSubmit.open ? React.createElement('div', { className: 'gho-pr-inline-box', style: { margin: '8px 0' } },
1509
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginBottom: '4px' } },
1510
+ React.createElement('button', {
1511
+ type: 'button',
1512
+ className: `gho-diagnose-btn${reviewSubmit.event === 'COMMENT' ? ' gho-badge-active' : ''}`,
1513
+ onClick: () => setReviewSubmit((s) => ({ ...s, event: 'COMMENT' })),
1514
+ }, t('panelPrComment')),
1515
+ React.createElement('button', {
1516
+ type: 'button',
1517
+ className: `gho-diagnose-btn${reviewSubmit.event === 'APPROVE' ? ' gho-badge-healthy' : ''}`,
1518
+ onClick: () => setReviewSubmit((s) => ({ ...s, event: 'APPROVE' })),
1519
+ }, t('panelPrApprove')),
1520
+ React.createElement('button', {
1521
+ type: 'button',
1522
+ className: `gho-diagnose-btn${reviewSubmit.event === 'REQUEST_CHANGES' ? ' gho-badge-failing' : ''}`,
1523
+ onClick: () => setReviewSubmit((s) => ({ ...s, event: 'REQUEST_CHANGES' })),
1524
+ }, t('panelPrRequestChanges')),
1525
+ ),
1526
+ React.createElement('textarea', {
1527
+ className: 'gho-panel-input',
1528
+ style: { minHeight: '60px', resize: 'vertical' },
1529
+ placeholder: t('panelPrReviewBody'),
1530
+ value: reviewSubmit.body,
1531
+ onChange: (e) => setReviewSubmit((s) => ({ ...s, body: e.target.value })),
1532
+ }),
1533
+ React.createElement('div', { style: { display: 'flex', gap: '6px', justifyContent: 'flex-end' } },
1534
+ React.createElement('button', { type: 'button', className: 'gho-diagnose-btn', onClick: () => setReviewSubmit({ open: false, body: '', event: 'COMMENT' }) }, t('scmAbort')),
1535
+ React.createElement('button', {
1536
+ type: 'button',
1537
+ className: 'gho-diagnose-btn',
1538
+ style: { background: 'var(--dsw-alias-color-primary, #1f6feb)', color: '#fff', border: 0 },
1539
+ disabled: Boolean(busy),
1540
+ onClick: () => sendAction('submit_review', { body: reviewSubmit.body, event: reviewSubmit.event }).then(() => setReviewSubmit({ open: false, body: '', event: 'COMMENT' })),
1541
+ }, t('panelPrSubmitReview')),
1542
+ ),
1543
+ ) : null
1544
+
1545
+ const filesList = React.createElement('div', null,
1546
+ React.createElement('div', { style: { fontWeight: 600, fontSize: '13px', margin: '10px 0 6px' } }, `${t('panelPrFilesChanged')} (${files.length})`),
1547
+ files.map((file, fIdx) => React.createElement('div', { key: file.filename || fIdx, className: 'gho-pr-file' },
1548
+ React.createElement('div', { className: 'gho-pr-file-head' },
1549
+ React.createElement('span', null, file.filename),
1550
+ React.createElement('span', { className: 'gho-panel-muted' }, `+${file.additions} -${file.deletions} · ${file.status}`),
1551
+ ),
1552
+ (!file.hunks || file.hunks.length === 0)
1553
+ ? React.createElement('div', { className: 'gho-panel-muted', style: { padding: '8px 12px' } }, t('panelPrEmptyDiff'))
1554
+ : file.hunks.map((hunk, hIdx) => React.createElement('div', { key: hIdx, className: 'gho-diff-hunk' },
1555
+ React.createElement('div', { className: 'gho-diff-hunk-head' },
1556
+ React.createElement('span', { className: 'gho-diff-hunk-title' }, hunk.header),
1557
+ ),
1558
+ React.createElement('div', { className: 'gho-diff-hunk-body' },
1559
+ hunk.lines.map((line, lIdx) => {
1560
+ const isHeader = line.type === 'hunk_header'
1561
+ if (isHeader) return null
1562
+ const targetLine = line.newLine || line.oldLine
1563
+ const isCommenting = activeComment && activeComment.path === file.filename && activeComment.line === targetLine
1564
+ const matchingThreads = threads.filter((th) => th.path === file.filename && (th.line === line.newLine || (!line.newLine && th.line === line.oldLine)))
1565
+
1566
+ return React.createElement('div', { key: lIdx, className: 'gho-pr-line-wrap' },
1567
+ React.createElement('div', { className: `gho-diff-line gho-diff-${line.type}` },
1568
+ React.createElement('div', {
1569
+ className: 'gho-pr-gutter',
1570
+ title: t('panelPrAddComment'),
1571
+ onClick: () => setActiveComment({ path: file.filename, line: targetLine, side: line.newLine ? 'RIGHT' : 'LEFT', body: '' }),
1572
+ },
1573
+ React.createElement('span', null, line.oldLine || ' '),
1574
+ React.createElement('span', null, line.newLine || ' '),
1575
+ ),
1576
+ React.createElement('span', { className: 'gho-diff-char' }, line.type === 'add' ? '+' : (line.type === 'del' ? '-' : ' ')),
1577
+ React.createElement('span', { className: 'gho-diff-text' }, line.type === 'add' || line.type === 'del' ? line.text.slice(1) : line.text),
1578
+ ),
1579
+ isCommenting ? React.createElement('div', { className: 'gho-pr-inline-box' },
1580
+ React.createElement('textarea', {
1581
+ className: 'gho-panel-input',
1582
+ style: { minHeight: '50px' },
1583
+ placeholder: t('panelPrCommentPlaceholder'),
1584
+ value: activeComment.body,
1585
+ onChange: (e) => setActiveComment((prev) => ({ ...prev, body: e.target.value })),
1586
+ }),
1587
+ React.createElement('div', { style: { display: 'flex', gap: '6px', justifyContent: 'space-between' } },
1588
+ React.createElement('button', {
1589
+ type: 'button',
1590
+ className: 'gho-diagnose-btn',
1591
+ onClick: () => {
1592
+ const snippet = line.type === 'add' || line.type === 'del' ? line.text.slice(1) : line.text
1593
+ setActiveComment((prev) => ({ ...prev, body: (prev.body ? prev.body + '\n' : '') + '```suggestion\n' + snippet + '\n```' }))
1594
+ },
1595
+ }, t('panelPrInsertSuggestion')),
1596
+ React.createElement('div', { style: { display: 'flex', gap: '6px' } },
1597
+ React.createElement('button', { type: 'button', className: 'gho-diagnose-btn', onClick: () => setActiveComment(null) }, t('scmAbort')),
1598
+ React.createElement('button', {
1599
+ type: 'button',
1600
+ className: 'gho-diagnose-btn',
1601
+ style: { background: 'var(--dsw-alias-color-primary, #1f6feb)', color: '#fff', border: 0 },
1602
+ disabled: Boolean(busy) || !activeComment.body.trim(),
1603
+ onClick: () => sendAction('comment', { path: activeComment.path, line: activeComment.line, side: activeComment.side, body: activeComment.body }).then(() => setActiveComment(null)),
1604
+ }, t('panelPrAddComment')),
1605
+ ),
1606
+ ),
1607
+ ) : null,
1608
+ matchingThreads.map((th, thIdx) => React.createElement('div', { key: th.id || thIdx, className: 'gho-pr-thread' },
1609
+ React.createElement('div', { className: 'gho-pr-thread-head' },
1610
+ React.createElement('span', null, `Thread #${String(th.id).slice(-6)}`),
1611
+ React.createElement('span', { className: `gho-badge gho-badge-${th.isResolved ? 'healthy' : 'building'}` }, th.isResolved ? t('panelPrResolved') : t('panelPrUnresolved')),
1612
+ ),
1613
+ (th.comments || []).map((c, cIdx) => React.createElement('div', { key: c.id || cIdx, className: 'gho-pr-comment' },
1614
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', color: 'var(--dsw-alias-label-secondary)' } },
1615
+ React.createElement('strong', null, `@${c.author}`),
1616
+ React.createElement('span', null, c.createdAt ? new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''),
1617
+ ),
1618
+ React.createElement('div', { style: { whiteSpace: 'pre-wrap', margin: '4px 0' } }, c.body),
1619
+ c.body && c.body.includes('```suggestion') ? React.createElement('button', {
1620
+ type: 'button',
1621
+ className: 'gho-diagnose-btn',
1622
+ style: { background: 'rgba(99,102,241,0.15)', color: '#818cf8', border: 0, margin: '4px 0' },
1623
+ disabled: Boolean(busy),
1624
+ onClick: () => sendAction('apply_suggestion', { commentId: c.databaseId || c.id, message: 'Apply suggestion' }),
1625
+ }, t('panelPrApplySuggestion')) : null,
1626
+ )),
1627
+ React.createElement('div', { style: { display: 'flex', gap: '6px', marginTop: '6px', justifyContent: 'flex-end' } },
1628
+ replyState.threadId === th.id ? React.createElement('div', { style: { width: '100%', display: 'flex', flexDirection: 'column', gap: '4px' } },
1629
+ React.createElement('input', {
1630
+ className: 'gho-panel-input',
1631
+ placeholder: t('panelPrReplyPlaceholder'),
1632
+ value: replyState.body,
1633
+ onChange: (e) => setReplyState((s) => ({ ...s, body: e.target.value })),
1634
+ }),
1635
+ React.createElement('div', { style: { display: 'flex', gap: '6px', justifyContent: 'flex-end' } },
1636
+ React.createElement('button', { type: 'button', className: 'gho-diagnose-btn', onClick: () => setReplyState({ threadId: null, commentId: null, body: '' }) }, t('scmAbort')),
1637
+ React.createElement('button', {
1638
+ type: 'button',
1639
+ className: 'gho-diagnose-btn',
1640
+ style: { background: 'var(--dsw-alias-color-primary, #1f6feb)', color: '#fff', border: 0 },
1641
+ disabled: Boolean(busy) || !replyState.body.trim(),
1642
+ onClick: () => sendAction('reply_thread', { threadId: th.id, commentId: th.comments?.[0]?.databaseId, body: replyState.body }).then(() => setReplyState({ threadId: null, commentId: null, body: '' })),
1643
+ }, t('panelPrReply')),
1644
+ ),
1645
+ ) : React.createElement('div', { style: { display: 'flex', gap: '6px' } },
1646
+ React.createElement('button', {
1647
+ type: 'button',
1648
+ className: 'gho-diagnose-btn',
1649
+ onClick: () => setReplyState({ threadId: th.id, commentId: th.comments?.[0]?.databaseId, body: '' }),
1650
+ }, t('panelPrReply')),
1651
+ React.createElement('button', {
1652
+ type: 'button',
1653
+ className: 'gho-diagnose-btn',
1654
+ disabled: Boolean(busy),
1655
+ onClick: () => sendAction('resolve_thread', { threadId: th.id, resolve: !th.isResolved }),
1656
+ }, th.isResolved ? t('panelPrUnresolve') : t('panelPrResolve')),
1657
+ ),
1658
+ ),
1659
+ )),
1660
+ )
1661
+ }),
1662
+ ),
1663
+ )),
1664
+ )),
1665
+ )
1666
+
1667
+ return React.createElement('div', { className: 'gho-pr-workspace' },
1668
+ header,
1669
+ reviewSubmitBox,
1670
+ filesList,
1671
+ )
1672
+ }
1673
+
1674
+
1339
1675
  function makeTranslator(locale) {
1340
1676
  const dict = String(locale || '').startsWith('zh') ? zh : en
1341
1677
  const fallback = en
package/lib/routes.js CHANGED
@@ -20,6 +20,7 @@ import { generateChangelog } from './tools/changelog.js'
20
20
  import { listNotifications, repoTreeView } from './tools/panel-data.js'
21
21
  import { mirrorStatus, syncMirror } from './tools/mirror.js'
22
22
  import { matrixStatus } from './tools/matrix.js'
23
+ import { listReviewThreads, replyReviewThread, resolveReviewThread, applySuggestion } from './tools/pulls.js'
23
24
 
24
25
  /** Where the settings card reads the access status from. */
25
26
  export const STATUS_PATH = '/dsh-github-ops/status'
@@ -42,6 +43,9 @@ export const GRAPH_PATH = '/dsh-github-ops/graph'
42
43
  /** Where real-time CI and PR events are streamed via SSE. */
43
44
  export const EVENTS_PATH = '/dsh-github-ops/events'
44
45
 
46
+ /** Where inline PR review workspace reads diff and posts line reviews. */
47
+ export const PR_REVIEW_PATH = '/dsh-github-ops/pr-review'
48
+
45
49
  /**
46
50
  * Register the three routes.
47
51
  *
@@ -615,6 +619,203 @@ export function registerRoutes(ctx, deps = {}) {
615
619
  if (typeof wctx.effect === 'function') wctx.effect(registerEvents, 'dsh-github-ops: events route')
616
620
  else registerEvents()
617
621
 
622
+ const registerPrReview = () => wctx.webServer.register({
623
+ kind: 'exact',
624
+ path: PR_REVIEW_PATH,
625
+ handler: async (req, res) => {
626
+ try {
627
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
628
+ if (req.method === 'POST') {
629
+ if (!isTrustedWriteRequest(req, routeOptions())) {
630
+ const status = getWriteRejectionStatus(req, routeOptions()) || 403
631
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
632
+ }
633
+ } else {
634
+ if (!isTrustedRequest(req, routeOptions())) {
635
+ const status = getRejectionStatus(req, routeOptions()) || 403
636
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
637
+ }
638
+ }
639
+ const access = await resolveToken()
640
+ if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
641
+ const github = await client()
642
+
643
+ if (req.method === 'GET') {
644
+ const url = new URL(req.url || '/', 'http://localhost')
645
+ const repoSpec = url.searchParams.get('repo') || ''
646
+ const number = Number(url.searchParams.get('number'))
647
+ const [repoOwner, repoName] = repoSpec.split('/')
648
+ if (!repoOwner || !repoName || !number) {
649
+ return sendJson(res, 400, { configured: true, error: 'repo and number are required' })
650
+ }
651
+
652
+ const [pullRes, filesRes] = await Promise.all([
653
+ github.get(`/repos/${repoOwner}/${repoName}/pulls/${number}`),
654
+ github.get(`/repos/${repoOwner}/${repoName}/pulls/${number}/files`, { query: { per_page: 100 } }),
655
+ ])
656
+
657
+ const pull = pullRes.data
658
+ const rawFiles = Array.isArray(filesRes.data) ? filesRes.data : []
659
+ const files = rawFiles.map((f) => {
660
+ const patch = typeof f.patch === 'string' ? f.patch : ''
661
+ return {
662
+ filename: f.filename,
663
+ status: f.status,
664
+ additions: f.additions,
665
+ deletions: f.deletions,
666
+ patch,
667
+ hunks: parseDiffHunks(patch),
668
+ }
669
+ })
670
+
671
+ let threads = []
672
+ try {
673
+ const thData = await listReviewThreads(github, { owner: repoOwner, repo: repoName, number })
674
+ threads = thData.threads || []
675
+ } catch {
676
+ try {
677
+ const { data: rawComments } = await github.get(`/repos/${repoOwner}/${repoName}/pulls/${number}/comments`, { query: { per_page: 100 } })
678
+ if (Array.isArray(rawComments)) {
679
+ const threadMap = new Map()
680
+ for (const c of rawComments) {
681
+ const rootId = c.in_reply_to_id || c.id
682
+ if (!threadMap.has(rootId)) {
683
+ threadMap.set(rootId, {
684
+ id: String(rootId),
685
+ path: c.path,
686
+ line: c.line || c.original_line || null,
687
+ diffSide: c.side || 'RIGHT',
688
+ isResolved: false,
689
+ comments: [],
690
+ })
691
+ }
692
+ threadMap.get(rootId).comments.push({
693
+ id: String(c.id),
694
+ databaseId: c.id,
695
+ author: (c.user && c.user.login) || 'unknown',
696
+ body: c.body || '',
697
+ createdAt: c.created_at,
698
+ url: c.html_url,
699
+ })
700
+ }
701
+ threads = Array.from(threadMap.values())
702
+ }
703
+ } catch {}
704
+ }
705
+
706
+ return sendJson(res, 200, {
707
+ configured: true,
708
+ pull: {
709
+ number: pull.number,
710
+ title: pull.title,
711
+ body: pull.body || '',
712
+ state: pull.state,
713
+ author: (pull.user && pull.user.login) || 'unknown',
714
+ base: pull.base && pull.base.ref,
715
+ head: pull.head && pull.head.ref,
716
+ headSha: pull.head && pull.head.sha,
717
+ htmlUrl: pull.html_url,
718
+ additions: pull.additions,
719
+ deletions: pull.deletions,
720
+ changedFiles: pull.changed_files,
721
+ merged: Boolean(pull.merged),
722
+ mergeable: pull.mergeable,
723
+ },
724
+ files,
725
+ threads,
726
+ })
727
+ }
728
+
729
+ if (req.method === 'POST') {
730
+ let body = {}
731
+ try {
732
+ body = await readJsonBody(req)
733
+ } catch (err) {
734
+ return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
735
+ }
736
+ const { action, repo: repoSpec, number } = body
737
+ const [repoOwner, repoName] = (repoSpec || '').split('/')
738
+ if (!repoOwner || !repoName) {
739
+ return sendJson(res, 400, { ok: false, error: 'repo (owner/repo) is required' })
740
+ }
741
+
742
+ if (action === 'comment') {
743
+ const { path, line, body: commentBody, commit_id, side } = body
744
+ if (!commentBody || !path || !line) {
745
+ return sendJson(res, 400, { ok: false, error: 'path, line, and body are required' })
746
+ }
747
+ let commitSha = commit_id
748
+ if (!commitSha) {
749
+ const { data: pr } = await github.get(`/repos/${repoOwner}/${repoName}/pulls/${number}`)
750
+ commitSha = pr.head && pr.head.sha
751
+ }
752
+ const { data: comment } = await github.post(`/repos/${repoOwner}/${repoName}/pulls/${number}/comments`, {
753
+ body: commentBody,
754
+ commit_id: commitSha,
755
+ path,
756
+ line: Number(line),
757
+ side: side || 'RIGHT',
758
+ })
759
+ return sendJson(res, 200, { ok: true, comment })
760
+ }
761
+
762
+ if (action === 'reply_thread') {
763
+ const { threadId, commentId, body: replyBody } = body
764
+ const resData = await replyReviewThread(github, {
765
+ owner: repoOwner,
766
+ repo: repoName,
767
+ number: Number(number),
768
+ threadId,
769
+ commentId,
770
+ body: replyBody,
771
+ })
772
+ return sendJson(res, 200, { ok: true, ...resData })
773
+ }
774
+
775
+ if (action === 'resolve_thread') {
776
+ const { threadId, resolve } = body
777
+ const resData = await resolveReviewThread(github, {
778
+ owner: repoOwner,
779
+ repo: repoName,
780
+ threadId,
781
+ resolve: resolve !== false,
782
+ })
783
+ return sendJson(res, 200, { ok: true, ...resData })
784
+ }
785
+
786
+ if (action === 'apply_suggestion') {
787
+ const { commentId, commentIds, message } = body
788
+ const resData = await applySuggestion(github, {
789
+ owner: repoOwner,
790
+ repo: repoName,
791
+ pullNumber: Number(number),
792
+ commentId,
793
+ commentIds,
794
+ confirm: true,
795
+ message,
796
+ })
797
+ return sendJson(res, 200, { ok: true, ...resData })
798
+ }
799
+
800
+ if (action === 'submit_review') {
801
+ const { body: reviewBody, event } = body
802
+ const { data: review } = await github.post(`/repos/${repoOwner}/${repoName}/pulls/${number}/reviews`, {
803
+ body: reviewBody || '',
804
+ event: event || 'COMMENT',
805
+ })
806
+ return sendJson(res, 200, { ok: true, review })
807
+ }
808
+
809
+ return sendJson(res, 400, { ok: false, error: `unknown action: ${action}` })
810
+ }
811
+ } catch (err) {
812
+ return sendJson(res, 500, { ok: false, error: describeError(err) })
813
+ }
814
+ },
815
+ })
816
+ if (typeof wctx.effect === 'function') wctx.effect(registerPrReview, 'dsh-github-ops: pr-review route')
817
+ else registerPrReview()
818
+
618
819
  } catch (err) {
619
820
  ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
620
821
  }
package/lib/scm.js CHANGED
@@ -385,6 +385,8 @@ export function parseDiffHunks(diffText) {
385
385
 
386
386
  const hunks = []
387
387
  let currentHunk = null
388
+ let curOld = 0
389
+ let curNew = 0
388
390
 
389
391
  while (i < lines.length) {
390
392
  const line = lines[i]
@@ -394,22 +396,39 @@ export function parseDiffHunks(diffText) {
394
396
  hunks.push(currentHunk)
395
397
  }
396
398
  const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
399
+ const oldStart = match ? parseInt(match[1], 10) : 0
400
+ const oldCount = match ? (match[2] !== undefined ? parseInt(match[2], 10) : 1) : 0
401
+ const newStart = match ? parseInt(match[3], 10) : 0
402
+ const newCount = match ? (match[4] !== undefined ? parseInt(match[4], 10) : 1) : 0
403
+ curOld = oldStart
404
+ curNew = newStart
397
405
  currentHunk = {
398
406
  header: line,
399
- oldStart: match ? parseInt(match[1], 10) : 0,
400
- oldCount: match ? (match[2] !== undefined ? parseInt(match[2], 10) : 1) : 0,
401
- newStart: match ? parseInt(match[3], 10) : 0,
402
- newCount: match ? (match[4] !== undefined ? parseInt(match[4], 10) : 1) : 0,
407
+ oldStart,
408
+ oldCount,
409
+ newStart,
410
+ newCount,
403
411
  heading: match ? match[5].trim() : '',
404
- lines: [{ type: 'hunk_header', text: line, raw: line }],
412
+ lines: [{ type: 'hunk_header', text: line, raw: line, oldLine: null, newLine: null }],
405
413
  patch: '',
406
414
  }
407
415
  } else if (currentHunk) {
408
416
  let type = 'context'
409
- if (line.startsWith('+')) type = 'add'
410
- else if (line.startsWith('-')) type = 'del'
411
- else if (line.startsWith('\\')) type = 'meta'
412
- currentHunk.lines.push({ type, text: line, raw: line })
417
+ let oldLine = null
418
+ let newLine = null
419
+ if (line.startsWith('+')) {
420
+ type = 'add'
421
+ newLine = curNew++
422
+ } else if (line.startsWith('-')) {
423
+ type = 'del'
424
+ oldLine = curOld++
425
+ } else if (line.startsWith('\\')) {
426
+ type = 'meta'
427
+ } else {
428
+ oldLine = curOld++
429
+ newLine = curNew++
430
+ }
431
+ currentHunk.lines.push({ type, text: line, raw: line, oldLine, newLine })
413
432
  }
414
433
  i++
415
434
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
5
5
  "license": "MIT",
6
6
  "type": "module",