@goodandready/dsh-github-ops 0.6.12 → 0.6.14
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 +6 -1
- package/lib/client.js +336 -0
- package/lib/routes.js +201 -0
- package/lib/scm.js +28 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.6.
|
|
3
|
+
## 0.6.14
|
|
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
|
+
|
|
8
|
+
## 0.6.13
|
|
4
9
|
|
|
5
10
|
### Features
|
|
6
11
|
- **GitHub Discussions GraphQL Tools (#118)**: Added `gh_discussion_list`, `gh_discussion_view`, `gh_discussion_create`, and `gh_discussion_comment` tools using GitHub GraphQL API with `confirm: true` protection for creating discussions and posting threaded replies.
|
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
|
|
400
|
-
oldCount
|
|
401
|
-
newStart
|
|
402
|
-
newCount
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.14",
|
|
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",
|