@magland/mochi 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +166 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +193 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/pagescript.js
CHANGED
|
@@ -142,19 +142,48 @@ function findKey(e, input) {
|
|
|
142
142
|
// and kept for the tab, so typing costs nothing after the first opening.
|
|
143
143
|
var jumpRepos = null;
|
|
144
144
|
var jumpCtx = null;
|
|
145
|
+
var jumpCtxRead = false;
|
|
145
146
|
var jumpSel = 0;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
// A path on this origin, or null. The context is read out of the page, and
|
|
148
|
+
// a rendered document shares the page; an href that is not a local path is
|
|
149
|
+
// not one the box will send anybody to.
|
|
150
|
+
function localPath(s) {
|
|
151
|
+
return typeof s === 'string' && s.charAt(0) === '/' && s.charAt(1) !== '/' && s.charAt(1) !== '\\\\' ? s : null;
|
|
152
|
+
}
|
|
153
|
+
// The repository the page is about, read when the box first opens rather than
|
|
154
|
+
// when this file loads: the script is in <head>, so at load time the body,
|
|
155
|
+
// and the element, do not exist yet. The selector names the script tag the
|
|
156
|
+
// template emits (src/views.ts); a rendered document cannot contain one.
|
|
157
|
+
function jumpContext() {
|
|
158
|
+
if (jumpCtxRead) return jumpCtx;
|
|
159
|
+
jumpCtxRead = true;
|
|
160
|
+
var el = document.querySelector('script#jump-data[type="application/json"]');
|
|
161
|
+
if (!el) return null;
|
|
162
|
+
var raw;
|
|
163
|
+
try { raw = JSON.parse(el.textContent); } catch (e) { return null; }
|
|
164
|
+
if (!raw || typeof raw.repo !== 'string') return null;
|
|
165
|
+
var sections = [];
|
|
166
|
+
var list = Array.isArray(raw.sections) ? raw.sections : [];
|
|
167
|
+
for (var i = 0; i < list.length; i++) {
|
|
168
|
+
var href = localPath(list[i] && list[i].href);
|
|
169
|
+
if (href && typeof list[i].label === 'string') sections.push({ label: list[i].label, href: href });
|
|
170
|
+
}
|
|
171
|
+
var searchUrl = localPath(raw.searchUrl);
|
|
172
|
+
var findUrl = localPath(raw.findUrl);
|
|
173
|
+
if (!searchUrl || !findUrl) return null;
|
|
174
|
+
jumpCtx = { repo: raw.repo, sections: sections, searchUrl: searchUrl, findUrl: findUrl };
|
|
175
|
+
return jumpCtx;
|
|
176
|
+
}
|
|
150
177
|
function loadJumpRepos() {
|
|
151
178
|
if (jumpRepos) return Promise.resolve(jumpRepos);
|
|
152
179
|
var cached = null;
|
|
153
|
-
|
|
180
|
+
// The key carries a version: the entries grew topics, and a tab holding the
|
|
181
|
+
// older list of bare names would otherwise feed it to code expecting objects.
|
|
182
|
+
try { cached = sessionStorage.getItem('mochi.repos.v2'); } catch (e) {}
|
|
154
183
|
if (cached) { try { jumpRepos = JSON.parse(cached); return Promise.resolve(jumpRepos); } catch (e) {} }
|
|
155
184
|
return fetch('/assets/repos.json').then(function (r) { return r.json(); }).then(function (list) {
|
|
156
185
|
jumpRepos = list;
|
|
157
|
-
try { sessionStorage.setItem('mochi.repos', JSON.stringify(list)); } catch (e) {}
|
|
186
|
+
try { sessionStorage.setItem('mochi.repos.v2', JSON.stringify(list)); } catch (e) {}
|
|
158
187
|
return list;
|
|
159
188
|
}, function () { jumpRepos = []; return jumpRepos; });
|
|
160
189
|
}
|
|
@@ -181,6 +210,7 @@ function jumpScore(hay, q) {
|
|
|
181
210
|
function jumpItems() {
|
|
182
211
|
var q = document.getElementById('jump-q').value.trim().toLowerCase();
|
|
183
212
|
var out = [];
|
|
213
|
+
var jumpCtx = jumpContext();
|
|
184
214
|
if (jumpCtx) {
|
|
185
215
|
for (var s = 0; s < jumpCtx.sections.length; s++) {
|
|
186
216
|
var sec = jumpCtx.sections[s];
|
|
@@ -190,17 +220,27 @@ function jumpItems() {
|
|
|
190
220
|
}
|
|
191
221
|
var repos = jumpRepos || [];
|
|
192
222
|
for (var i = 0; i < repos.length; i++) {
|
|
193
|
-
var
|
|
223
|
+
var entry = repos[i];
|
|
224
|
+
var name = typeof entry === 'string' ? entry : entry.name;
|
|
225
|
+
var topics = (entry && entry.topics) || [];
|
|
194
226
|
var slash = name.indexOf('/');
|
|
195
227
|
var short = name.slice(slash + 1);
|
|
228
|
+
var note = name.slice(0, slash);
|
|
196
229
|
var sc = jumpScore(short, q);
|
|
197
230
|
// Falling back to the whole path lets "concept/bench" find a repository,
|
|
198
231
|
// but only as a literal substring: a collection name spread across it a
|
|
199
232
|
// letter at a time would match nearly everything.
|
|
200
233
|
if (sc < 0 && q !== '' && name.toLowerCase().indexOf(q) !== -1) sc = 950;
|
|
234
|
+
// Last, the topics: typing one finds what carries it, below any match on
|
|
235
|
+
// a name, and the note says which topic answered.
|
|
236
|
+
if (sc < 0 && q !== '') {
|
|
237
|
+
for (var t = 0; t < topics.length; t++) {
|
|
238
|
+
if (topics[t].indexOf(q) !== -1) { sc = 960; note = note + ' · ' + topics[t]; break; }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
201
241
|
// A repository is what the box is mostly for, so its matches sort above a
|
|
202
242
|
// section of equal quality rather than below.
|
|
203
|
-
if (sc >= 0) out.push({ score: sc - 1, group: 'Repositories', label: short, note:
|
|
243
|
+
if (sc >= 0) out.push({ score: sc - 1, group: 'Repositories', label: short, note: note, href: '/' + name.split('/').map(encodeURIComponent).join('/') });
|
|
204
244
|
}
|
|
205
245
|
out.sort(function (a, b) { return a.score - b.score || a.label.localeCompare(b.label); });
|
|
206
246
|
out = out.slice(0, 15);
|
|
@@ -222,7 +262,7 @@ function renderJump() {
|
|
|
222
262
|
var it = items[i];
|
|
223
263
|
if (it.group !== group) { group = it.group; html += '<li class="jump-group" role="presentation">' + escapeHtml(group) + '</li>'; }
|
|
224
264
|
html += '<li role="option" aria-selected="' + (i === jumpSel) + '"><a class="jump-item' + (i === jumpSel ? ' on' : '') +
|
|
225
|
-
'" href="' + escapeHtml(it.href) + '" data-jump-index="' + i + '">' + escapeHtml(it.label) +
|
|
265
|
+
'" href="' + escapeHtml(it.href) + '" data-jump-index="' + i + '"><span class="jump-name">' + escapeHtml(it.label) + '</span>' +
|
|
226
266
|
(it.note ? '<span class="jump-note">' + escapeHtml(it.note) + '</span>' : '') + '</a></li>';
|
|
227
267
|
}
|
|
228
268
|
list.innerHTML = html || '<li class="jump-empty" role="presentation">' +
|
|
@@ -267,6 +307,20 @@ document.addEventListener('click', function (e) {
|
|
|
267
307
|
var dlg = document.getElementById('jump');
|
|
268
308
|
if (dlg && dlg.open && e.target === dlg) dlg.close();
|
|
269
309
|
});
|
|
310
|
+
// Ids inside a rendered document carry a user-content- prefix (see
|
|
311
|
+
// src/markdown.ts), so a link from outside it to /repo#install names an
|
|
312
|
+
// element that is not there under that name. When the fragment finds nothing,
|
|
313
|
+
// the prefixed element is scrolled to instead, on load and on every change.
|
|
314
|
+
function followHash() {
|
|
315
|
+
var h = location.hash ? String(location.hash).slice(1) : '';
|
|
316
|
+
if (!h || document.getElementById(h)) return;
|
|
317
|
+
var name;
|
|
318
|
+
try { name = decodeURIComponent(h); } catch (e) { name = h; }
|
|
319
|
+
var el = document.getElementById('user-content-' + name);
|
|
320
|
+
if (el && el.scrollIntoView) el.scrollIntoView();
|
|
321
|
+
}
|
|
322
|
+
document.addEventListener('DOMContentLoaded', followHash);
|
|
323
|
+
window.addEventListener('hashchange', followHash);
|
|
270
324
|
// The filter box above a listing: hide the rows that do not match, and say so
|
|
271
325
|
// when none do.
|
|
272
326
|
function filterRows(input) {
|
|
@@ -314,6 +368,129 @@ function filterMenu(input) {
|
|
|
314
368
|
}
|
|
315
369
|
}
|
|
316
370
|
|
|
371
|
+
// ---- passkeys ----
|
|
372
|
+
//
|
|
373
|
+
// The WebAuthn ceremonies, client half. The server speaks JSON on
|
|
374
|
+
// /login/passkey* and /account/passkeys*, with every binary field base64url,
|
|
375
|
+
// which is also the encoding the WebAuthn API's own ids use. Buttons carry
|
|
376
|
+
// data attributes and start hidden; support is detected here, so a browser
|
|
377
|
+
// without WebAuthn (or a page served over plain http, where the API does not
|
|
378
|
+
// exist) simply never shows them.
|
|
379
|
+
|
|
380
|
+
function pkDecode(s) {
|
|
381
|
+
s = String(s).replace(/-/g, '+').replace(/_/g, '/');
|
|
382
|
+
while (s.length % 4) s += '=';
|
|
383
|
+
var bin = atob(s);
|
|
384
|
+
var out = new Uint8Array(bin.length);
|
|
385
|
+
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
386
|
+
return out.buffer;
|
|
387
|
+
}
|
|
388
|
+
function pkEncode(buf) {
|
|
389
|
+
var b = new Uint8Array(buf);
|
|
390
|
+
var bin = '';
|
|
391
|
+
for (var i = 0; i < b.length; i++) bin += String.fromCharCode(b[i]);
|
|
392
|
+
return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
393
|
+
}
|
|
394
|
+
function pkSay(message) {
|
|
395
|
+
var el = document.querySelector('[data-passkey-error]');
|
|
396
|
+
if (!el) return;
|
|
397
|
+
el.textContent = message || '';
|
|
398
|
+
el.hidden = !message;
|
|
399
|
+
}
|
|
400
|
+
function pkFetch(url, body) {
|
|
401
|
+
return fetch(url, {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: { 'content-type': 'application/json' },
|
|
404
|
+
body: JSON.stringify(body),
|
|
405
|
+
}).then(function (r) {
|
|
406
|
+
return r.json().catch(function () { return {}; }).then(function (d) {
|
|
407
|
+
if (!r.ok) throw new Error(d.error || ('the server answered ' + r.status));
|
|
408
|
+
return d;
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
// A cancelled or timed-out prompt is the user's decision, not a fault to
|
|
413
|
+
// paint red; anything else is said as the server or browser said it.
|
|
414
|
+
function pkProblem(e) {
|
|
415
|
+
if (e && e.name === 'NotAllowedError') pkSay('Cancelled, or the prompt timed out. Try again when ready.');
|
|
416
|
+
else pkSay((e && e.message) || 'Something went wrong.');
|
|
417
|
+
}
|
|
418
|
+
function pkRegister(btn) {
|
|
419
|
+
var form = closestOf(btn, '[data-passkey-form]');
|
|
420
|
+
var csrf = form.querySelector('input[name=csrf]').value;
|
|
421
|
+
var label = (form.querySelector('input[name=name]') || { value: '' }).value;
|
|
422
|
+
pkSay('');
|
|
423
|
+
btn.disabled = true;
|
|
424
|
+
pkFetch('/account/passkeys/challenge', { csrf: csrf })
|
|
425
|
+
.then(function (o) {
|
|
426
|
+
var publicKey = {
|
|
427
|
+
challenge: pkDecode(o.challenge),
|
|
428
|
+
rp: o.rp,
|
|
429
|
+
user: { id: pkDecode(o.user.id), name: o.user.name, displayName: o.user.displayName },
|
|
430
|
+
pubKeyCredParams: o.pubKeyCredParams,
|
|
431
|
+
authenticatorSelection: o.authenticatorSelection,
|
|
432
|
+
timeout: o.timeout,
|
|
433
|
+
attestation: o.attestation,
|
|
434
|
+
excludeCredentials: (o.excludeCredentials || []).map(function (c) {
|
|
435
|
+
return { type: 'public-key', id: pkDecode(c.id) };
|
|
436
|
+
}),
|
|
437
|
+
};
|
|
438
|
+
return navigator.credentials.create({ publicKey: publicKey });
|
|
439
|
+
})
|
|
440
|
+
.then(function (cred) {
|
|
441
|
+
return pkFetch('/account/passkeys', {
|
|
442
|
+
csrf: csrf,
|
|
443
|
+
name: label,
|
|
444
|
+
id: cred.id,
|
|
445
|
+
clientDataJSON: pkEncode(cred.response.clientDataJSON),
|
|
446
|
+
attestationObject: pkEncode(cred.response.attestationObject),
|
|
447
|
+
});
|
|
448
|
+
})
|
|
449
|
+
.then(function (d) { location.href = d.next; })
|
|
450
|
+
.catch(pkProblem)
|
|
451
|
+
.then(function () { btn.disabled = false; });
|
|
452
|
+
}
|
|
453
|
+
function pkLogin(btn) {
|
|
454
|
+
pkSay('');
|
|
455
|
+
btn.disabled = true;
|
|
456
|
+
pkFetch('/login/passkey/challenge', {})
|
|
457
|
+
.then(function (o) {
|
|
458
|
+
return navigator.credentials.get({
|
|
459
|
+
publicKey: {
|
|
460
|
+
challenge: pkDecode(o.challenge),
|
|
461
|
+
rpId: o.rpId,
|
|
462
|
+
timeout: o.timeout,
|
|
463
|
+
userVerification: o.userVerification,
|
|
464
|
+
allowCredentials: [],
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
})
|
|
468
|
+
.then(function (cred) {
|
|
469
|
+
return pkFetch('/login/passkey', {
|
|
470
|
+
next: btn.getAttribute('data-next') || '/',
|
|
471
|
+
id: cred.id,
|
|
472
|
+
clientDataJSON: pkEncode(cred.response.clientDataJSON),
|
|
473
|
+
authenticatorData: pkEncode(cred.response.authenticatorData),
|
|
474
|
+
signature: pkEncode(cred.response.signature),
|
|
475
|
+
userHandle: cred.response.userHandle ? pkEncode(cred.response.userHandle) : null,
|
|
476
|
+
});
|
|
477
|
+
})
|
|
478
|
+
.then(function (d) { location.href = d.next; })
|
|
479
|
+
.catch(pkProblem)
|
|
480
|
+
.then(function () { btn.disabled = false; });
|
|
481
|
+
}
|
|
482
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
483
|
+
var supported = Boolean(window.PublicKeyCredential);
|
|
484
|
+
var login = document.querySelector('[data-passkey-login]');
|
|
485
|
+
if (login && supported) login.hidden = false;
|
|
486
|
+
var add = document.querySelector('[data-passkey-add]');
|
|
487
|
+
var unsupported = document.querySelector('[data-passkey-unsupported]');
|
|
488
|
+
if (add && !supported) {
|
|
489
|
+
add.disabled = true;
|
|
490
|
+
if (unsupported) unsupported.hidden = false;
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
317
494
|
// ---- wiring ----
|
|
318
495
|
//
|
|
319
496
|
// The markup carries data attributes rather than inline handlers, and the
|
|
@@ -346,6 +523,14 @@ document.addEventListener('click', function (e) {
|
|
|
346
523
|
if (lines) { copyLines(lines); return; }
|
|
347
524
|
var select = closestOf(t, '[data-select-all]');
|
|
348
525
|
if (select) { select.select(); return; }
|
|
526
|
+
var pkAdd = closestOf(t, '[data-passkey-add]');
|
|
527
|
+
if (pkAdd) { pkRegister(pkAdd); return; }
|
|
528
|
+
var pkGo = closestOf(t, '[data-passkey-login-btn]');
|
|
529
|
+
if (pkGo) { pkLogin(pkGo); return; }
|
|
530
|
+
var mdTab = closestOf(t, '[data-md-pane]');
|
|
531
|
+
if (mdTab) { mdPane(mdEditorOf(mdTab), mdTab.getAttribute('data-md-pane')); return; }
|
|
532
|
+
var mdBtn = closestOf(t, '[data-md-act]');
|
|
533
|
+
if (mdBtn) { mdAct(mdEditorOf(mdBtn), mdBtn.getAttribute('data-md-act')); return; }
|
|
349
534
|
});
|
|
350
535
|
|
|
351
536
|
document.addEventListener('input', function (e) {
|
|
@@ -368,6 +553,14 @@ document.addEventListener('keydown', function (e) {
|
|
|
368
553
|
if (!el || !el.matches) return;
|
|
369
554
|
if (el.id === 'jump-q') { jumpKey(e); return; }
|
|
370
555
|
if (el.matches('.find-input')) { findKey(e, el); return; }
|
|
556
|
+
// The formatting shortcuts, only inside a markdown editor's field: the same
|
|
557
|
+
// four GitHub answers to, so hands need not relearn anything.
|
|
558
|
+
if (el.tagName === 'TEXTAREA' && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
|
|
559
|
+
var ed = mdEditorOf(el);
|
|
560
|
+
if (!ed) return;
|
|
561
|
+
var act = { b: 'bold', i: 'italic', k: 'link', e: 'code' }[String(e.key).toLowerCase()];
|
|
562
|
+
if (act) { e.preventDefault(); mdAct(ed, act); }
|
|
563
|
+
}
|
|
371
564
|
});
|
|
372
565
|
|
|
373
566
|
// mouseenter does not bubble, so the jump list is followed with mouseover,
|
|
@@ -399,27 +592,165 @@ function pickWorkflow(sel) {
|
|
|
399
592
|
}
|
|
400
593
|
}
|
|
401
594
|
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
595
|
+
// ---- the markdown editor ----
|
|
596
|
+
//
|
|
597
|
+
// The toolbar writes markdown into the textarea rather than styling anything:
|
|
598
|
+
// what is stored is what was typed. Every button is a toggle where a toggle
|
|
599
|
+
// makes sense (bold twice is plain again), and the Preview tab shows the draft
|
|
600
|
+
// through the same renderer the saved page will use, fetched from the
|
|
601
|
+
// repository's own preview endpoint so relative links, #12, and commit ids
|
|
602
|
+
// resolve as they will when saved.
|
|
603
|
+
|
|
604
|
+
function mdEditorOf(el) { return closestOf(el, '[data-md-editor]'); }
|
|
605
|
+
|
|
606
|
+
// Replace [start, end) with text, leave [selStart, selEnd) selected, and keep
|
|
607
|
+
// the field focused so the next keystroke lands where the edit did.
|
|
608
|
+
function mdSet(input, start, end, text, selStart, selEnd) {
|
|
609
|
+
input.focus();
|
|
610
|
+
input.setRangeText(text, start, end, 'preserve');
|
|
611
|
+
input.setSelectionRange(selStart, selEnd);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Wrap the selection in the marks, or take them off again when they are
|
|
615
|
+
// already there, inside the selection or just around it.
|
|
616
|
+
function mdSurround(input, before, after, placeholder) {
|
|
617
|
+
var s = input.selectionStart, e = input.selectionEnd, v = input.value;
|
|
618
|
+
var sel = v.slice(s, e);
|
|
619
|
+
if (sel.length >= before.length + after.length &&
|
|
620
|
+
sel.slice(0, before.length) === before && sel.slice(sel.length - after.length) === after) {
|
|
621
|
+
var inner = sel.slice(before.length, sel.length - after.length);
|
|
622
|
+
mdSet(input, s, e, inner, s, s + inner.length);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (s >= before.length && v.slice(s - before.length, s) === before && v.slice(e, e + after.length) === after) {
|
|
626
|
+
mdSet(input, s - before.length, e + after.length, sel, s - before.length, s - before.length + sel.length);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
var body = sel || placeholder || '';
|
|
630
|
+
mdSet(input, s, e, before + body + after, s + before.length, s + before.length + body.length);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// The whole lines the selection touches, and where they sit in the value.
|
|
634
|
+
function mdLines(input) {
|
|
635
|
+
var v = input.value;
|
|
636
|
+
var s = v.lastIndexOf('\\n', input.selectionStart - 1) + 1;
|
|
637
|
+
var e = v.indexOf('\\n', input.selectionEnd);
|
|
638
|
+
if (e === -1) e = v.length;
|
|
639
|
+
return { start: s, end: e, lines: v.slice(s, e).split('\\n') };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Prefix each selected line, or strip the prefix when every line has one
|
|
643
|
+
// already; blank lines between prefixed ones are passed over.
|
|
644
|
+
function mdPrefix(input, make, strip) {
|
|
645
|
+
var r = mdLines(input);
|
|
646
|
+
var on = r.lines.every(function (l) { return l === '' || strip.test(l); });
|
|
647
|
+
var out = [];
|
|
648
|
+
for (var i = 0; i < r.lines.length; i++) {
|
|
649
|
+
var l = r.lines[i];
|
|
650
|
+
out.push(on ? l.replace(strip, '') : (l === '' && r.lines.length > 1 ? l : make(i) + l));
|
|
651
|
+
}
|
|
652
|
+
var text = out.join('\\n');
|
|
653
|
+
mdSet(input, r.start, r.end, text, r.start, r.start + text.length);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// A link: a selected URL becomes the target with the cursor in the text, and
|
|
657
|
+
// anything else becomes the text with the url placeholder selected.
|
|
658
|
+
function mdLink(input) {
|
|
659
|
+
var s = input.selectionStart, e = input.selectionEnd;
|
|
660
|
+
var sel = input.value.slice(s, e);
|
|
661
|
+
if (/^https?:\\/\\/\\S+$/.test(sel)) {
|
|
662
|
+
mdSet(input, s, e, '[](' + sel + ')', s + 1, s + 1);
|
|
663
|
+
} else {
|
|
664
|
+
var body = sel || 'link text';
|
|
665
|
+
mdSet(input, s, e, '[' + body + '](url)', s + body.length + 3, s + body.length + 6);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function mdAct(ed, act) {
|
|
670
|
+
var input = ed && ed.querySelector('textarea');
|
|
671
|
+
if (!input || input.hidden) return;
|
|
672
|
+
if (act === 'bold') return mdSurround(input, '**', '**', 'bold text');
|
|
673
|
+
if (act === 'italic') return mdSurround(input, '_', '_', 'italic text');
|
|
674
|
+
if (act === 'strike') return mdSurround(input, '~~', '~~', 'struck text');
|
|
675
|
+
if (act === 'link') return mdLink(input);
|
|
676
|
+
if (act === 'code') {
|
|
677
|
+
var sel = input.value.slice(input.selectionStart, input.selectionEnd);
|
|
678
|
+
if (sel.indexOf('\\n') !== -1) return mdSurround(input, '\`\`\`\\n', '\\n\`\`\`', '');
|
|
679
|
+
return mdSurround(input, '\`', '\`', 'code');
|
|
680
|
+
}
|
|
681
|
+
if (act === 'heading') return mdPrefix(input, function () { return '### '; }, /^#{1,6} /);
|
|
682
|
+
if (act === 'quote') return mdPrefix(input, function () { return '> '; }, /^> /);
|
|
683
|
+
if (act === 'ul') return mdPrefix(input, function () { return '- '; }, /^[-*] (?!\\[[ xX]\\] )/);
|
|
684
|
+
if (act === 'ol') return mdPrefix(input, function (i) { return (i + 1) + '. '; }, /^\\d+\\. /);
|
|
685
|
+
if (act === 'task') return mdPrefix(input, function () { return '- [ ] '; }, /^[-*] \\[[ xX]\\] /);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// The tabs. Write is the field back; Preview posts the draft, with the CSRF
|
|
689
|
+
// value of the form it sits in, and shows what came back. The height of the
|
|
690
|
+
// field is kept so the page does not jump, and an unchanged draft is not
|
|
691
|
+
// posted again.
|
|
692
|
+
function mdPane(ed, pane) {
|
|
693
|
+
var input = ed.querySelector('textarea');
|
|
694
|
+
var render = ed.querySelector('.md-render');
|
|
695
|
+
var tabs = ed.querySelectorAll('[data-md-pane]');
|
|
696
|
+
for (var i = 0; i < tabs.length; i++) {
|
|
697
|
+
var on = tabs[i].getAttribute('data-md-pane') === pane;
|
|
698
|
+
tabs[i].classList[on ? 'add' : 'remove']('current');
|
|
699
|
+
tabs[i].setAttribute('aria-selected', String(on));
|
|
700
|
+
}
|
|
701
|
+
var previewing = pane === 'preview';
|
|
702
|
+
ed.classList[previewing ? 'add' : 'remove']('previewing');
|
|
703
|
+
input.hidden = previewing;
|
|
704
|
+
render.hidden = !previewing;
|
|
705
|
+
if (!previewing) { input.focus(); return; }
|
|
706
|
+
render.style.minHeight = input.offsetHeight + 'px';
|
|
707
|
+
if (ed.mdShown === input.value) return;
|
|
708
|
+
render.innerHTML = '<p class="muted">Rendering\\u2026</p>';
|
|
709
|
+
var form = closestOf(ed, 'form');
|
|
710
|
+
var csrf = form && form.querySelector('input[name=csrf]');
|
|
711
|
+
var params = new URLSearchParams();
|
|
712
|
+
params.set('csrf', csrf ? csrf.value : '');
|
|
713
|
+
params.set('text', input.value);
|
|
714
|
+
params.set('ref', ed.getAttribute('data-md-ref') || '');
|
|
715
|
+
params.set('dir', ed.getAttribute('data-md-dir') || '');
|
|
716
|
+
fetch(ed.getAttribute('data-md-preview'), { method: 'POST', body: params })
|
|
717
|
+
.then(function (r) { if (!r.ok) throw new Error('preview ' + r.status); return r.text(); })
|
|
718
|
+
.then(function (h) { ed.mdShown = input.value; render.innerHTML = h; })
|
|
719
|
+
.catch(function () { render.innerHTML = '<p class="muted">The preview could not be rendered. The draft is untouched.</p>'; });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// The editors: in one of the code kind, Tab indents rather than leaving the
|
|
723
|
+
// field, since code is what is being typed (Shift-Tab still moves focus out,
|
|
724
|
+
// so the keyboard is not trapped). Navigating away from any edited buffer asks
|
|
725
|
+
// first; committing does not ask, since submitting is the point.
|
|
406
726
|
document.addEventListener('DOMContentLoaded', function () {
|
|
407
|
-
var
|
|
408
|
-
if (!
|
|
409
|
-
var
|
|
410
|
-
var initial = ta.value;
|
|
727
|
+
var tas = document.querySelectorAll('textarea.code-editor, [data-md-editor] textarea');
|
|
728
|
+
if (!tas.length) return;
|
|
729
|
+
var initial = [];
|
|
411
730
|
var submitted = false;
|
|
412
|
-
|
|
731
|
+
function watch(ta) {
|
|
732
|
+
initial.push(ta.value);
|
|
733
|
+
var form = ta.closest('form');
|
|
734
|
+
if (form && !form.mdWatched) {
|
|
735
|
+
form.mdWatched = true;
|
|
736
|
+
form.addEventListener('submit', function () { submitted = true; });
|
|
737
|
+
}
|
|
738
|
+
if (!ta.classList.contains('code-editor')) return;
|
|
739
|
+
ta.addEventListener('keydown', function (e) {
|
|
740
|
+
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
741
|
+
e.preventDefault();
|
|
742
|
+
ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end');
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
for (var i = 0; i < tas.length; i++) watch(tas[i]);
|
|
413
746
|
window.addEventListener('beforeunload', function (e) {
|
|
414
|
-
if (submitted
|
|
747
|
+
if (submitted) return;
|
|
748
|
+
var dirty = false;
|
|
749
|
+
for (var i = 0; i < tas.length; i++) if (tas[i].value !== initial[i]) dirty = true;
|
|
750
|
+
if (!dirty) return;
|
|
415
751
|
e.preventDefault();
|
|
416
752
|
e.returnValue = '';
|
|
417
753
|
});
|
|
418
|
-
ta.addEventListener('keydown', function (e) {
|
|
419
|
-
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
420
|
-
e.preventDefault();
|
|
421
|
-
ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end');
|
|
422
|
-
});
|
|
423
754
|
});
|
|
424
755
|
|
|
425
756
|
// A live job's log: the endpoint and the offset to resume from are on the
|
package/dist/perms.js
CHANGED
|
@@ -44,6 +44,7 @@ exports.collectionOwners = collectionOwners;
|
|
|
44
44
|
exports.isCollectionOwner = isCollectionOwner;
|
|
45
45
|
exports.addCollectionOwner = addCollectionOwner;
|
|
46
46
|
exports.removeCollectionOwner = removeCollectionOwner;
|
|
47
|
+
exports.tokenIsScoped = tokenIsScoped;
|
|
47
48
|
exports.isSiteAdmin = isSiteAdmin;
|
|
48
49
|
exports.repoRole = repoRole;
|
|
49
50
|
exports.canReadRepo = canReadRepo;
|
package/dist/profile.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.collectionProfile = collectionProfile;
|
|
|
5
5
|
const markdown_1 = require("./markdown");
|
|
6
6
|
const render_1 = require("./render");
|
|
7
7
|
const scan_1 = require("./scan");
|
|
8
|
+
const vault_1 = require("./vault");
|
|
8
9
|
const views_1 = require("./views");
|
|
9
10
|
/**
|
|
10
11
|
* A collection's profile README: the page a collection shows above its
|
|
@@ -63,6 +64,7 @@ async function collectionProfile(root, collection) {
|
|
|
63
64
|
blobBase: `${base}/blob/${(0, views_1.encPath)(branch)}/${exports.PROFILE_DIR}`,
|
|
64
65
|
issueBase: `${base}/issues`,
|
|
65
66
|
commitBase: `${base}/commit`,
|
|
67
|
+
mentions: (name) => (0, vault_1.userExists)(root, name),
|
|
66
68
|
})
|
|
67
69
|
: `<pre>${(0, render_1.esc)(readme.text)}</pre>`;
|
|
68
70
|
return {
|
package/dist/pulls.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.MergeConflict = exports.MAX_TITLE = exports.MAX_BODY = void 0;
|
|
36
|
+
exports.MergeConflict = exports.DuplicatePullError = exports.MAX_TITLE = exports.MAX_BODY = void 0;
|
|
37
37
|
exports.pullsDir = pullsDir;
|
|
38
38
|
exports.listPulls = listPulls;
|
|
39
39
|
exports.pullCounts = pullCounts;
|
|
@@ -144,6 +144,20 @@ function readPull(root, collection, repo, n) {
|
|
|
144
144
|
return null;
|
|
145
145
|
return { ...summaryFrom(n, dir, doc), body: doc.body, commentList: (0, discussion_1.readComments)(dir) };
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* A pull request refused because one is already open for the same base and
|
|
149
|
+
* head. Its own class because the number is what a caller wants: the web
|
|
150
|
+
* handler sends the reader to the existing pull request rather than
|
|
151
|
+
* describing it.
|
|
152
|
+
*/
|
|
153
|
+
class DuplicatePullError extends ops_1.OpError {
|
|
154
|
+
number;
|
|
155
|
+
constructor(number, head, base) {
|
|
156
|
+
super(`Pull request #${number} for ${head} into ${base} is already open.`, 'exists');
|
|
157
|
+
this.number = number;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
exports.DuplicatePullError = DuplicatePullError;
|
|
147
161
|
function createPull(root, collection, repo, input) {
|
|
148
162
|
const dir = pullsDir(root, collection, repo);
|
|
149
163
|
if (!dir)
|
|
@@ -152,6 +166,12 @@ function createPull(root, collection, repo, input) {
|
|
|
152
166
|
const body = (0, discussion_1.checkBody)(PULL, input.body);
|
|
153
167
|
if (input.base === input.head)
|
|
154
168
|
throw new ops_1.OpError('A branch cannot be merged into itself.');
|
|
169
|
+
// One open pull request per (base, head): a second would show the same
|
|
170
|
+
// commits and go stale the moment the first merges. The existing one is
|
|
171
|
+
// where new discussion belongs, so the refusal carries its number.
|
|
172
|
+
const open = listPulls(root, collection, repo).find((p) => p.state === 'open' && p.base === input.base && p.head === input.head);
|
|
173
|
+
if (open)
|
|
174
|
+
throw new DuplicatePullError(open.number, input.head, input.base);
|
|
155
175
|
const now = new Date().toISOString();
|
|
156
176
|
const n = (0, discussion_1.allocate)(PULL, dir, (sub) => (0, discussion_1.writeDoc)(path.join(sub, 'pull.md'), {
|
|
157
177
|
title,
|
package/dist/pullweb.js
CHANGED
|
@@ -41,6 +41,7 @@ const git_1 = require("./git");
|
|
|
41
41
|
const html_1 = require("./html");
|
|
42
42
|
const icons_1 = require("./icons");
|
|
43
43
|
const markdown_1 = require("./markdown");
|
|
44
|
+
const mdedit_1 = require("./mdedit");
|
|
44
45
|
const ops_1 = require("./ops");
|
|
45
46
|
const pulls = __importStar(require("./pulls"));
|
|
46
47
|
const render_1 = require("./render");
|
|
@@ -74,6 +75,7 @@ function body(ctx, text) {
|
|
|
74
75
|
blobBase: `${base}/blob/${ref}`,
|
|
75
76
|
issueBase: `${base}/issues`,
|
|
76
77
|
commitBase: `${base}/commit`,
|
|
78
|
+
mentions: ctx.hasUser,
|
|
77
79
|
}));
|
|
78
80
|
}
|
|
79
81
|
function stateBadge(state) {
|
|
@@ -101,7 +103,7 @@ function listPage(ctx, list, state, counts) {
|
|
|
101
103
|
const rows = list.map((p) => (0, html_1.html) `<tr>
|
|
102
104
|
<td class="issue-cell">${stateIcon(p.state)}<span>
|
|
103
105
|
<a class="issue-link" href="${base}/${p.number}">${p.title}</a>
|
|
104
|
-
<div class="muted small">#${p.number} opened ${(0, render_1.timeTag)(p.created)} by ${p.author} · ${branchPair(ctx, p)}</div></span></td>
|
|
106
|
+
<div class="muted small">#${p.number} opened ${(0, render_1.timeTag)(p.created)} by ${(0, views_1.userLink)(p.author)} · ${branchPair(ctx, p)}</div></span></td>
|
|
105
107
|
<td class="right muted small">${p.comments > 0
|
|
106
108
|
? (0, html_1.html) `<a class="issue-comments" href="${base}/${p.number}" title="${p.comments} comment${p.comments === 1 ? '' : 's'}">${(0, icons_1.icon)('comment')}<span>${p.comments}</span></a>`
|
|
107
109
|
: ''}</td>
|
|
@@ -126,7 +128,7 @@ ${rows.length
|
|
|
126
128
|
}
|
|
127
129
|
function commentCard(author, when, rendered, note = '') {
|
|
128
130
|
return (0, html_1.html) `<article class="issue-comment">
|
|
129
|
-
<div class="issue-comment-head">${(0,
|
|
131
|
+
<div class="issue-comment-head">${(0, views_1.userLink)(author, { face: 24, bold: true })}<span class="muted small">${note || 'commented'} ${(0, render_1.timeTag)(when)}</span></div>
|
|
130
132
|
<div class="issue-comment-body markdown-body">${rendered}</div>
|
|
131
133
|
</article>`;
|
|
132
134
|
}
|
|
@@ -185,21 +187,23 @@ function pullPage(ctx, pull, view) {
|
|
|
185
187
|
: '';
|
|
186
188
|
replyBox = (0, html_1.html) `<form class="issue-reply" method="post" action="${base}/${pull.number}/comment">${(0, views_1.csrfField)(viewer)}
|
|
187
189
|
<div class="issue-comment-head">${(0, avatar_1.avatar)(viewer.auth.username, 24)}<b>${viewer.auth.username}</b></div>
|
|
188
|
-
|
|
190
|
+
${(0, mdedit_1.markdownEditor)({ name: 'body', rows: 6, placeholder: 'Leave a comment', preview: (0, mdedit_1.previewUrl)(ctx) })}
|
|
189
191
|
<div class="actions">${toggle}<button class="btn btn-primary" type="submit">Comment</button></div>
|
|
190
192
|
</form>`;
|
|
191
193
|
}
|
|
192
194
|
else {
|
|
193
195
|
replyBox = (0, html_1.html) `<p class="muted"><a href="/login?next=${encodeURIComponent(`${base}/${pull.number}`)}">Sign in</a> to comment.</p>`;
|
|
194
196
|
}
|
|
195
|
-
const commitRows = view.commits.map((c) => (0, html_1.html) `<div class="commit-row"><span class="commit-main"><a class="title" href="${repo}/commit/${c.sha}">${c.subject}</a><div class="muted small">${c.author} committed ${(0, render_1.timeTag)(c.date)}</div></span><a class="sha" href="${repo}/commit/${c.sha}">${c.sha.slice(0, 7)}</a></div>`);
|
|
197
|
+
const commitRows = view.commits.map((c) => (0, html_1.html) `<div class="commit-row"><span class="commit-main"><a class="title" href="${repo}/commit/${c.sha}">${c.subject}</a><div class="muted small">${(0, views_1.gitAuthor)(ctx, c.author, c.email)} committed ${(0, render_1.timeTag)(c.date)}</div></span><a class="sha" href="${repo}/commit/${c.sha}">${c.sha.slice(0, 7)}</a></div>`);
|
|
196
198
|
const description = body(ctx, pull.body);
|
|
197
199
|
const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'pulls')}
|
|
198
200
|
<div class="issue-head">
|
|
199
201
|
<h1 class="issue-title">${pull.title} <span class="muted">#${pull.number}</span></h1>
|
|
200
202
|
<span class="right-group"><a class="btn" href="${base}">Back to pull requests</a></span>
|
|
201
203
|
</div>
|
|
202
|
-
<div class="issue-sub">${stateBadge(pull.state)}<span class="muted"
|
|
204
|
+
<div class="issue-sub">${stateBadge(pull.state)}<span class="muted">${(0, views_1.userLink)(pull.author, {
|
|
205
|
+
bold: true,
|
|
206
|
+
})} wants to merge ${view.ahead} commit${view.ahead === 1 ? '' : 's'} into </span>${branchPair(ctx, pull)}</div>
|
|
203
207
|
${mergeBox(ctx, pull, view.preview, ctx.canPush)}
|
|
204
208
|
<div class="issue-thread">
|
|
205
209
|
${commentCard(pull.author, pull.created, description.text ? description : (0, html_1.raw)('<p class="muted">No description.</p>'), 'opened this')}
|
|
@@ -233,7 +237,15 @@ ${info
|
|
|
233
237
|
<div class="field"><label for="title">Title</label>
|
|
234
238
|
<input type="text" id="title" name="title" value="${values.title}" maxlength="${pulls.MAX_TITLE}" required autofocus></div>
|
|
235
239
|
<div class="field"><label for="body">Description</label>
|
|
236
|
-
|
|
240
|
+
${(0, mdedit_1.markdownEditor)({
|
|
241
|
+
name: 'body',
|
|
242
|
+
id: 'body',
|
|
243
|
+
rows: 10,
|
|
244
|
+
placeholder: 'What this changes, and why. Markdown is welcome.',
|
|
245
|
+
value: values.body,
|
|
246
|
+
preview: (0, mdedit_1.previewUrl)(ctx),
|
|
247
|
+
ref: values.head,
|
|
248
|
+
})}</div>
|
|
237
249
|
<div class="actions"><button class="btn btn-primary" type="submit">${(0, icons_1.icon)('git-pull-request')}<span>Create pull request</span></button><a class="btn" href="${base}">Cancel</a></div>
|
|
238
250
|
</form>
|
|
239
251
|
</div>`;
|
|
@@ -319,6 +331,13 @@ function registerPulls(app, root, engine) {
|
|
|
319
331
|
res.redirect(`${pullsUrl(ctx)}/${created.number}`);
|
|
320
332
|
}
|
|
321
333
|
catch (e) {
|
|
334
|
+
// A pull request already open for this base and head is where this one
|
|
335
|
+
// was headed anyway, so the reader is sent there instead of shown a form
|
|
336
|
+
// error describing it.
|
|
337
|
+
if (e instanceof pulls.DuplicatePullError) {
|
|
338
|
+
res.redirect(`${pullsUrl(ctx)}/${e.number}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
322
341
|
const message = e instanceof ops_1.OpError ? e.message : 'Could not open the pull request.';
|
|
323
342
|
res
|
|
324
343
|
.status(400)
|
package/dist/releases.js
CHANGED
|
@@ -50,6 +50,7 @@ const git_1 = require("./git");
|
|
|
50
50
|
const html_1 = require("./html");
|
|
51
51
|
const icons_1 = require("./icons");
|
|
52
52
|
const markdown_1 = require("./markdown");
|
|
53
|
+
const mdedit_1 = require("./mdedit");
|
|
53
54
|
const render_1 = require("./render");
|
|
54
55
|
const session_1 = require("./session");
|
|
55
56
|
const layout_1 = require("./layout");
|
|
@@ -257,7 +258,14 @@ ${editing
|
|
|
257
258
|
<div class="field"><label for="name">Title</label>
|
|
258
259
|
<input type="text" id="name" name="name" maxlength="${MAX_NAME}" value="${editing?.name ?? ''}" placeholder="Leave empty to use the tag name"></div>
|
|
259
260
|
<div class="field"><label for="body">Notes</label>
|
|
260
|
-
|
|
261
|
+
${(0, mdedit_1.markdownEditor)({
|
|
262
|
+
name: 'body',
|
|
263
|
+
id: 'body',
|
|
264
|
+
rows: 16,
|
|
265
|
+
placeholder: 'What changed, and what it means for anyone upgrading. Markdown.',
|
|
266
|
+
value: editing?.body ?? '',
|
|
267
|
+
preview: (0, mdedit_1.previewUrl)(ctx),
|
|
268
|
+
})}</div>
|
|
261
269
|
<div class="field"><label class="checkbox"><input type="checkbox" name="prerelease" value="1"${editing?.prerelease ? (0, html_1.raw)(' checked') : ''}> This is a pre-release</label></div>
|
|
262
270
|
<div class="actions"><button type="submit" class="btn btn-primary">${editing ? 'Save release' : 'Publish release'}</button><a class="btn" href="${base}/releases">Cancel</a>${del}</div>
|
|
263
271
|
</form>
|
|
@@ -430,6 +438,7 @@ ${releaseCard(ctx, release, ref, latest, notesHtml(ctx, release))}`;
|
|
|
430
438
|
blobBase: `${base}/blob/${(0, views_1.encPath)(release.tag)}`,
|
|
431
439
|
issueBase: `${base}/issues`,
|
|
432
440
|
commitBase: `${base}/commit`,
|
|
441
|
+
mentions: ctx.hasUser,
|
|
433
442
|
});
|
|
434
443
|
}
|
|
435
444
|
}
|