design-share 0.2.0 → 0.3.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/bin/design-share.js +8 -1
- package/dist/DesignShare.app/Contents/CodeResources +0 -0
- package/dist/DesignShare.app/Contents/Info.plist +2 -2
- package/dist/DesignShare.app/Contents/MacOS/DesignShare +0 -0
- package/package.json +1 -1
- package/public/app.js +117 -6
- package/public/index.html +4 -0
- package/public/style.css +72 -0
- package/src/github.js +24 -0
- package/src/server.js +29 -0
- package/src/state.js +80 -1
package/bin/design-share.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
3
|
+
if (major < 18) {
|
|
4
|
+
console.error(`\n design-share needs Node 18 or newer. You are on ${process.versions.node}.`);
|
|
5
|
+
console.error(' Update at https://nodejs.org and run npx design-share again.\n');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { run } = await import('../src/cli.js');
|
|
3
10
|
|
|
4
11
|
run(process.argv.slice(2)).catch((err) => {
|
|
5
12
|
console.error(`\n \x1b[31m✕\x1b[0m ${err.message}`);
|
|
Binary file
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
<key>CFBundleIdentifier</key>
|
|
10
10
|
<string>com.design-share.menubar</string>
|
|
11
11
|
<key>CFBundleVersion</key>
|
|
12
|
-
<string>0.
|
|
12
|
+
<string>0.3.0</string>
|
|
13
13
|
<key>CFBundleShortVersionString</key>
|
|
14
|
-
<string>0.
|
|
14
|
+
<string>0.3.0</string>
|
|
15
15
|
<key>CFBundleExecutable</key>
|
|
16
16
|
<string>DesignShare</string>
|
|
17
17
|
<key>CFBundlePackageType</key>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-share",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Live branch previews and pinned comments for design teams. Zero config, no server, git is the backend.",
|
|
5
5
|
"keywords": ["design", "preview", "branches", "collaboration", "cli"],
|
|
6
6
|
"license": "MIT",
|
package/public/app.js
CHANGED
|
@@ -7,8 +7,68 @@ const icons = {
|
|
|
7
7
|
message: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
|
|
8
8
|
check: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
9
9
|
plus: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>',
|
|
10
|
+
eye: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
/* ---------- review requests ---------- */
|
|
14
|
+
|
|
15
|
+
function branchRequests(branch) {
|
|
16
|
+
if (!state.board) return [];
|
|
17
|
+
return (state.board.requests || [])
|
|
18
|
+
.filter((r) => r.branch === branch)
|
|
19
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function openRequestForMe(branch) {
|
|
23
|
+
const me = state.board.me.slug;
|
|
24
|
+
return branchRequests(branch).find((r) => r.toUser === me && !r.resolvedAt);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderRequestButton() {
|
|
28
|
+
const b = state.board;
|
|
29
|
+
const sel = state.selected;
|
|
30
|
+
const btn = el('btn-request');
|
|
31
|
+
const roster = (b.users || []).filter((u) => u.user !== b.me.slug);
|
|
32
|
+
const visible = !!sel && roster.length > 0;
|
|
33
|
+
btn.hidden = !visible;
|
|
34
|
+
if (visible) btn.innerHTML = `${icons.eye} <span>Request review</span>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toggleRequestPopover(force) {
|
|
38
|
+
const pop = el('request-popover');
|
|
39
|
+
const show = force !== undefined ? force : pop.hidden;
|
|
40
|
+
if (!show) { pop.hidden = true; return; }
|
|
41
|
+
const b = state.board;
|
|
42
|
+
const sel = state.selected;
|
|
43
|
+
if (!sel) return;
|
|
44
|
+
const roster = (b.users || [])
|
|
45
|
+
.filter((u) => u.user !== b.me.slug)
|
|
46
|
+
.sort((x, y) => (y.lastSeen || 0) - (x.lastSeen || 0));
|
|
47
|
+
pop.innerHTML = '';
|
|
48
|
+
const head = document.createElement('div');
|
|
49
|
+
head.className = 'popover-head';
|
|
50
|
+
head.textContent = `Ask someone to review ${sel.branch}`;
|
|
51
|
+
pop.appendChild(head);
|
|
52
|
+
for (const u of roster) {
|
|
53
|
+
const already = branchRequests(sel.branch).some((r) => r.toUser === u.user && !r.resolvedAt && r.fromUser === b.me.slug);
|
|
54
|
+
const row = document.createElement('button');
|
|
55
|
+
row.className = 'popover-row';
|
|
56
|
+
row.innerHTML = `
|
|
57
|
+
<span class="avatar">${escapeHtml((u.name || u.user).slice(0, 1))}</span>
|
|
58
|
+
<span class="popover-name">${escapeHtml(u.name || u.user)}</span>
|
|
59
|
+
<span class="popover-meta">${already ? 'asked' : timeAgo((u.lastSeen || 0) / 1000)}</span>`;
|
|
60
|
+
row.disabled = already;
|
|
61
|
+
row.onclick = async () => {
|
|
62
|
+
await api('/api/requests', { branch: sel.branch, toUser: u.user, toName: u.name });
|
|
63
|
+
toggleRequestPopover(false);
|
|
64
|
+
toast(`Asked ${u.name || u.user} to review ${sel.branch}`);
|
|
65
|
+
refresh();
|
|
66
|
+
};
|
|
67
|
+
pop.appendChild(row);
|
|
68
|
+
}
|
|
69
|
+
pop.hidden = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
12
72
|
const el = (id) => document.getElementById(id);
|
|
13
73
|
const state = {
|
|
14
74
|
board: null,
|
|
@@ -188,12 +248,22 @@ function branchRow({ user, branch, time, dim, own }) {
|
|
|
188
248
|
else if (isOwnServing && prev && prev.status === 'ready') dotCls = 'live';
|
|
189
249
|
|
|
190
250
|
const unresolved = openCount(branch);
|
|
251
|
+
const pr = b.prs && b.prs[branch];
|
|
252
|
+
const askedMe = openRequestForMe(branch);
|
|
191
253
|
btn.innerHTML = `
|
|
192
254
|
<span class="dot ${dotCls}"></span>
|
|
193
255
|
<span class="branch-label">${escapeHtml(branch)}</span>
|
|
256
|
+
${askedMe ? `<span class="badge review" title="${escapeHtml((askedMe.fromName || askedMe.fromUser) + ' asked for your review')}">review</span>` : ''}
|
|
194
257
|
${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
|
|
258
|
+
${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
|
|
195
259
|
<span class="meta">${timeAgo(time)}</span>`;
|
|
196
260
|
btn.onclick = () => select(user, branch);
|
|
261
|
+
if (pr) {
|
|
262
|
+
btn.querySelector('.pr-link').onclick = (e) => {
|
|
263
|
+
e.stopPropagation();
|
|
264
|
+
window.open(pr.url, '_blank');
|
|
265
|
+
};
|
|
266
|
+
}
|
|
197
267
|
return btn;
|
|
198
268
|
}
|
|
199
269
|
|
|
@@ -287,6 +357,8 @@ function renderStage() {
|
|
|
287
357
|
el('loading-log').textContent = ps && ps.logTail ? ps.logTail.trim() : '';
|
|
288
358
|
}
|
|
289
359
|
|
|
360
|
+
renderRequestButton();
|
|
361
|
+
|
|
290
362
|
const cm = el('btn-comment-mode');
|
|
291
363
|
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
|
|
292
364
|
cm.classList.toggle('active-mode', state.commentMode);
|
|
@@ -345,11 +417,13 @@ window.addEventListener('message', (e) => {
|
|
|
345
417
|
el('composer-text').value = '';
|
|
346
418
|
el('composer-text').focus();
|
|
347
419
|
} else if (d.ds === 'pin-click') {
|
|
348
|
-
state.focusedComment = d.id;
|
|
420
|
+
state.focusedComment = state.focusedComment === d.id ? null : d.id;
|
|
349
421
|
renderComments();
|
|
350
422
|
pushCommentsToFrame();
|
|
351
|
-
|
|
352
|
-
|
|
423
|
+
if (state.focusedComment) {
|
|
424
|
+
const card = document.querySelector(`[data-comment="${d.id}"]`);
|
|
425
|
+
if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
426
|
+
}
|
|
353
427
|
}
|
|
354
428
|
});
|
|
355
429
|
|
|
@@ -451,6 +525,34 @@ function renderComments() {
|
|
|
451
525
|
el('comments-count').textContent = open.length ? `${open.length} open` : '';
|
|
452
526
|
|
|
453
527
|
const frag = document.createDocumentFragment();
|
|
528
|
+
|
|
529
|
+
const me = state.board.me.slug;
|
|
530
|
+
for (const r of branchRequests(sel.branch).filter((x) => !x.resolvedAt)) {
|
|
531
|
+
const card = document.createElement('div');
|
|
532
|
+
card.className = 'request-card' + (r.toUser === me ? ' for-me' : '');
|
|
533
|
+
if (r.toUser === me) {
|
|
534
|
+
card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked for your review</span>
|
|
535
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
536
|
+
const done = document.createElement('button');
|
|
537
|
+
done.className = 'btn small';
|
|
538
|
+
done.textContent = 'Mark reviewed';
|
|
539
|
+
done.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'done' }); refresh(); };
|
|
540
|
+
card.appendChild(done);
|
|
541
|
+
} else if (r.fromUser === me) {
|
|
542
|
+
card.innerHTML = `<span class="request-text">You asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
|
|
543
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
544
|
+
const cancel = document.createElement('button');
|
|
545
|
+
cancel.className = 'link-btn';
|
|
546
|
+
cancel.textContent = 'Cancel';
|
|
547
|
+
cancel.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'cancelled' }); refresh(); };
|
|
548
|
+
card.appendChild(cancel);
|
|
549
|
+
} else {
|
|
550
|
+
card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
|
|
551
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
552
|
+
}
|
|
553
|
+
frag.appendChild(card);
|
|
554
|
+
}
|
|
555
|
+
|
|
454
556
|
if (!comments.length) {
|
|
455
557
|
const d = document.createElement('div');
|
|
456
558
|
d.className = 'empty-note';
|
|
@@ -531,11 +633,10 @@ function commentCard(c, num) {
|
|
|
531
633
|
|
|
532
634
|
card.onclick = (e) => {
|
|
533
635
|
if (e.target === reply || e.target.tagName === 'BUTTON') return;
|
|
534
|
-
state.focusedComment = c.id;
|
|
636
|
+
state.focusedComment = state.focusedComment === c.id ? null : c.id;
|
|
637
|
+
renderComments();
|
|
535
638
|
renderPins();
|
|
536
639
|
pushCommentsToFrame();
|
|
537
|
-
document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
|
|
538
|
-
card.classList.add('focused');
|
|
539
640
|
};
|
|
540
641
|
return card;
|
|
541
642
|
}
|
|
@@ -568,6 +669,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
568
669
|
if (e.key === 'Escape') {
|
|
569
670
|
if (!el('composer').hidden) closeComposer();
|
|
570
671
|
else if (state.commentMode) { state.commentMode = false; renderStage(); }
|
|
672
|
+
else if (state.focusedComment) {
|
|
673
|
+
state.focusedComment = null;
|
|
674
|
+
renderComments();
|
|
675
|
+
renderPins();
|
|
676
|
+
pushCommentsToFrame();
|
|
677
|
+
}
|
|
571
678
|
}
|
|
572
679
|
});
|
|
573
680
|
|
|
@@ -615,6 +722,10 @@ function parseHash() {
|
|
|
615
722
|
state.railFilter = e.target.value;
|
|
616
723
|
renderRail();
|
|
617
724
|
});
|
|
725
|
+
el('btn-request').onclick = (e) => { e.stopPropagation(); toggleRequestPopover(); };
|
|
726
|
+
document.addEventListener('click', (e) => {
|
|
727
|
+
if (!el('request-popover').hidden && !e.target.closest('.request-wrap')) toggleRequestPopover(false);
|
|
728
|
+
});
|
|
618
729
|
renderViewportSeg();
|
|
619
730
|
await refresh();
|
|
620
731
|
const target = parseHash();
|
package/public/index.html
CHANGED
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
</div>
|
|
32
32
|
<div class="stage-actions">
|
|
33
33
|
<div class="seg" id="viewport-seg"></div>
|
|
34
|
+
<div class="request-wrap">
|
|
35
|
+
<button class="btn ghost" id="btn-request"></button>
|
|
36
|
+
<div id="request-popover" hidden></div>
|
|
37
|
+
</div>
|
|
34
38
|
<button class="btn" id="btn-comment-mode"></button>
|
|
35
39
|
</div>
|
|
36
40
|
</header>
|
package/public/style.css
CHANGED
|
@@ -123,6 +123,78 @@ button { font: inherit; cursor: pointer; }
|
|
|
123
123
|
.branch-row .meta { font-size: 11px; color: var(--text-3); flex: none; }
|
|
124
124
|
.branch-row.dim { color: var(--text-2); }
|
|
125
125
|
|
|
126
|
+
.pr-link {
|
|
127
|
+
font-size: 10.5px;
|
|
128
|
+
color: var(--text-3);
|
|
129
|
+
border: 1px solid var(--border);
|
|
130
|
+
border-radius: 99px;
|
|
131
|
+
padding: 0 6px;
|
|
132
|
+
line-height: 16px;
|
|
133
|
+
flex: none;
|
|
134
|
+
background: #fff;
|
|
135
|
+
}
|
|
136
|
+
.pr-link:hover { color: var(--accent); border-color: var(--accent); }
|
|
137
|
+
|
|
138
|
+
.badge.review { color: var(--amber); border-color: #ecd9b8; background: #fdf8ee; }
|
|
139
|
+
|
|
140
|
+
.request-wrap { position: relative; }
|
|
141
|
+
#request-popover {
|
|
142
|
+
position: absolute;
|
|
143
|
+
top: 32px;
|
|
144
|
+
right: 0;
|
|
145
|
+
width: 240px;
|
|
146
|
+
background: var(--panel);
|
|
147
|
+
border: 1px solid var(--border);
|
|
148
|
+
border-radius: 8px;
|
|
149
|
+
box-shadow: var(--shadow);
|
|
150
|
+
padding: 6px;
|
|
151
|
+
z-index: 20;
|
|
152
|
+
}
|
|
153
|
+
.popover-head {
|
|
154
|
+
font-size: 11px;
|
|
155
|
+
color: var(--text-3);
|
|
156
|
+
padding: 5px 8px 7px;
|
|
157
|
+
border-bottom: 1px solid var(--border);
|
|
158
|
+
margin-bottom: 4px;
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
overflow: hidden;
|
|
161
|
+
text-overflow: ellipsis;
|
|
162
|
+
}
|
|
163
|
+
.popover-row {
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
gap: 8px;
|
|
167
|
+
width: 100%;
|
|
168
|
+
border: 0;
|
|
169
|
+
background: transparent;
|
|
170
|
+
border-radius: var(--radius);
|
|
171
|
+
padding: 6px 8px;
|
|
172
|
+
font-size: 12.5px;
|
|
173
|
+
color: var(--text);
|
|
174
|
+
text-align: left;
|
|
175
|
+
}
|
|
176
|
+
.popover-row:hover:not(:disabled) { background: #f2f2f4; }
|
|
177
|
+
.popover-row:disabled { opacity: 0.5; cursor: default; }
|
|
178
|
+
.popover-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
179
|
+
.popover-meta { font-size: 10.5px; color: var(--text-3); flex: none; }
|
|
180
|
+
|
|
181
|
+
.request-card {
|
|
182
|
+
display: flex;
|
|
183
|
+
align-items: center;
|
|
184
|
+
gap: 8px;
|
|
185
|
+
flex-wrap: wrap;
|
|
186
|
+
border: 1px solid var(--border);
|
|
187
|
+
border-radius: 8px;
|
|
188
|
+
padding: 8px 10px;
|
|
189
|
+
margin-bottom: 8px;
|
|
190
|
+
font-size: 12px;
|
|
191
|
+
color: var(--text-2);
|
|
192
|
+
background: #fafafa;
|
|
193
|
+
}
|
|
194
|
+
.request-card.for-me { border-color: #ecd9b8; background: #fdf8ee; color: var(--text); }
|
|
195
|
+
.request-card .request-text { flex: 1; min-width: 120px; }
|
|
196
|
+
.request-card .request-meta { font-size: 10.5px; color: var(--text-3); }
|
|
197
|
+
|
|
126
198
|
.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
127
199
|
.dot.live { background: var(--green); }
|
|
128
200
|
.dot.idle { background: #d4d4d8; }
|
package/src/github.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// PR links come from the gh CLI so no tokens are stored or handled: gh uses
|
|
4
|
+
// whoever is already logged in. Machines without gh (or without auth) simply
|
|
5
|
+
// return an empty map and the dashboard shows no PR links.
|
|
6
|
+
export function listOpenPRs(repoRoot) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
execFile('gh', [
|
|
9
|
+
'pr', 'list', '--state', 'open', '--limit', '200',
|
|
10
|
+
'--json', 'number,url,headRefName,title',
|
|
11
|
+
], { cwd: repoRoot, timeout: 15_000 }, (err, stdout) => {
|
|
12
|
+
if (err) return resolve({});
|
|
13
|
+
try {
|
|
14
|
+
const byBranch = {};
|
|
15
|
+
for (const pr of JSON.parse(stdout)) {
|
|
16
|
+
byBranch[pr.headRefName] = { number: pr.number, url: pr.url, title: pr.title };
|
|
17
|
+
}
|
|
18
|
+
resolve(byBranch);
|
|
19
|
+
} catch {
|
|
20
|
+
resolve({});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
package/src/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { tryGit } from './git.js';
|
|
6
6
|
import { currentBranch } from './detect.js';
|
|
7
7
|
import { serveStatic } from './previews.js';
|
|
8
|
+
import { listOpenPRs } from './github.js';
|
|
8
9
|
|
|
9
10
|
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
|
10
11
|
const SYNC_INTERVAL_MS = 15_000;
|
|
@@ -36,6 +37,11 @@ export class DesignShareServer {
|
|
|
36
37
|
this.port = port;
|
|
37
38
|
this.server = null;
|
|
38
39
|
this.timers = [];
|
|
40
|
+
this.prs = {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async refreshPRs() {
|
|
44
|
+
this.prs = await listOpenPRs(this.repo.root);
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
async branchHeads() {
|
|
@@ -83,7 +89,10 @@ export class DesignShareServer {
|
|
|
83
89
|
heads,
|
|
84
90
|
otherBranches: others,
|
|
85
91
|
comments: Object.values(state.comments || {}),
|
|
92
|
+
users: Object.values(state.users || {}),
|
|
93
|
+
requests: Object.values(state.requests || {}),
|
|
86
94
|
previews: this.previews.statuses(),
|
|
95
|
+
prs: this.prs,
|
|
87
96
|
sync: {
|
|
88
97
|
hasRemote: this.store.hasRemote,
|
|
89
98
|
lastSync: this.store.lastSync,
|
|
@@ -138,9 +147,27 @@ export class DesignShareServer {
|
|
|
138
147
|
commit,
|
|
139
148
|
text: String(body.text || '').slice(0, 4000),
|
|
140
149
|
});
|
|
150
|
+
this.store.autoResolveRequests(body.branch);
|
|
141
151
|
this.syncSoon();
|
|
142
152
|
return json(res, 200, { ok: true, id });
|
|
143
153
|
}
|
|
154
|
+
if (req.method === 'POST' && p === '/api/requests') {
|
|
155
|
+
const body = await readBody(req);
|
|
156
|
+
const id = this.store.requestReview({
|
|
157
|
+
branch: String(body.branch || ''),
|
|
158
|
+
toUser: String(body.toUser || ''),
|
|
159
|
+
toName: String(body.toName || body.toUser || ''),
|
|
160
|
+
});
|
|
161
|
+
this.syncSoon();
|
|
162
|
+
return json(res, 200, { ok: true, id });
|
|
163
|
+
}
|
|
164
|
+
const reqResolveMatch = p.match(/^\/api\/requests\/([a-z0-9]+)\/resolve$/);
|
|
165
|
+
if (req.method === 'POST' && reqResolveMatch) {
|
|
166
|
+
const body = await readBody(req);
|
|
167
|
+
this.store.resolveRequest(reqResolveMatch[1], body.how);
|
|
168
|
+
this.syncSoon();
|
|
169
|
+
return json(res, 200, { ok: true });
|
|
170
|
+
}
|
|
144
171
|
const replyMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/replies$/);
|
|
145
172
|
if (req.method === 'POST' && replyMatch) {
|
|
146
173
|
const body = await readBody(req);
|
|
@@ -202,6 +229,8 @@ export class DesignShareServer {
|
|
|
202
229
|
() => tryGit(this.repo.root, ['fetch', '--quiet', 'origin']),
|
|
203
230
|
FETCH_INTERVAL_MS,
|
|
204
231
|
));
|
|
232
|
+
this.refreshPRs();
|
|
233
|
+
this.timers.push(setInterval(() => this.refreshPRs(), 180_000));
|
|
205
234
|
resolve(port);
|
|
206
235
|
});
|
|
207
236
|
};
|
package/src/state.js
CHANGED
|
@@ -5,7 +5,7 @@ const REF = 'refs/design-share/state';
|
|
|
5
5
|
const REMOTE_REF = 'refs/design-share/remote';
|
|
6
6
|
|
|
7
7
|
export function emptyState() {
|
|
8
|
-
return { version: 1, shares: {}, comments: {} };
|
|
8
|
+
return { version: 1, shares: {}, comments: {}, users: {}, requests: {} };
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function newId() {
|
|
@@ -45,6 +45,18 @@ export function mergeStates(a, b) {
|
|
|
45
45
|
out.comments[k] = ca || cb;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
const userKeys = new Set([...Object.keys(a.users || {}), ...Object.keys(b.users || {})]);
|
|
49
|
+
for (const k of userKeys) {
|
|
50
|
+
const ua = (a.users || {})[k];
|
|
51
|
+
const ub = (b.users || {})[k];
|
|
52
|
+
out.users[k] = ua && ub ? ((ua.lastSeen || 0) >= (ub.lastSeen || 0) ? ua : ub) : (ua || ub);
|
|
53
|
+
}
|
|
54
|
+
const reqKeys = new Set([...Object.keys(a.requests || {}), ...Object.keys(b.requests || {})]);
|
|
55
|
+
for (const k of reqKeys) {
|
|
56
|
+
const ra = (a.requests || {})[k];
|
|
57
|
+
const rb = (b.requests || {})[k];
|
|
58
|
+
out.requests[k] = ra && rb ? newer(ra, rb) : (ra || rb);
|
|
59
|
+
}
|
|
48
60
|
return out;
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -93,8 +105,22 @@ export class StateStore {
|
|
|
93
105
|
await git(this.repoRoot, ['update-ref', REF, commit]);
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
// Presence: mark this user as someone who runs design-share so the
|
|
109
|
+
// review request picker only offers reachable people. Throttled so it
|
|
110
|
+
// does not force a push on every sync cycle.
|
|
111
|
+
stampPresence() {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const u = (this.state.users || {})[this.identity.slug];
|
|
114
|
+
if (u && now - (u.lastSeen || 0) < 60 * 60 * 1000) return;
|
|
115
|
+
this.mutate((s) => {
|
|
116
|
+
s.users = s.users || {};
|
|
117
|
+
s.users[this.identity.slug] = { user: this.identity.slug, name: this.identity.name, lastSeen: now };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
// Fetch remote state, merge, persist locally, push if anything moved.
|
|
97
122
|
async sync() {
|
|
123
|
+
this.stampPresence();
|
|
98
124
|
try {
|
|
99
125
|
let remoteState = null;
|
|
100
126
|
if (this.hasRemote) {
|
|
@@ -175,6 +201,59 @@ export class StateStore {
|
|
|
175
201
|
return id;
|
|
176
202
|
}
|
|
177
203
|
|
|
204
|
+
requestReview({ branch, toUser, toName }) {
|
|
205
|
+
const id = newId();
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
this.mutate((s) => {
|
|
208
|
+
s.requests = s.requests || {};
|
|
209
|
+
// One open request per branch per person: refresh an existing one.
|
|
210
|
+
const existing = Object.values(s.requests).find(
|
|
211
|
+
(r) => r.branch === branch && r.toUser === toUser && !r.resolvedAt,
|
|
212
|
+
);
|
|
213
|
+
const key = existing ? existing.id : id;
|
|
214
|
+
s.requests[key] = {
|
|
215
|
+
id: key,
|
|
216
|
+
branch,
|
|
217
|
+
fromUser: this.identity.slug,
|
|
218
|
+
fromName: this.identity.name,
|
|
219
|
+
toUser,
|
|
220
|
+
toName,
|
|
221
|
+
createdAt: existing ? existing.createdAt : now,
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
resolvedAt: null,
|
|
224
|
+
resolvedBy: null,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
return id;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
resolveRequest(requestId, how) {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
this.mutate((s) => {
|
|
233
|
+
const r = (s.requests || {})[requestId];
|
|
234
|
+
if (!r) return;
|
|
235
|
+
r.resolvedAt = now;
|
|
236
|
+
r.resolvedBy = this.identity.slug;
|
|
237
|
+
r.resolvedHow = how || 'done';
|
|
238
|
+
r.updatedAt = now;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A comment from the requested person is the review happening; close the loop.
|
|
243
|
+
autoResolveRequests(branch) {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
this.mutate((s) => {
|
|
246
|
+
for (const r of Object.values(s.requests || {})) {
|
|
247
|
+
if (r.branch === branch && r.toUser === this.identity.slug && !r.resolvedAt) {
|
|
248
|
+
r.resolvedAt = now;
|
|
249
|
+
r.resolvedBy = this.identity.slug;
|
|
250
|
+
r.resolvedHow = 'commented';
|
|
251
|
+
r.updatedAt = now;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
178
257
|
reply(commentId, text) {
|
|
179
258
|
const now = Date.now();
|
|
180
259
|
this.mutate((s) => {
|