@supersuit/suggest-a-change 0.1.13 → 0.2.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/CHANGELOG.md +18 -0
- package/README.md +162 -7
- package/dist/client/adapters.d.ts +27 -0
- package/dist/client/context.d.ts +61 -0
- package/dist/client/context.js +279 -0
- package/dist/client/detail.d.ts +2 -2
- package/dist/client/detail.js +12 -8
- package/dist/client/entry.js +2 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/client/mine.js +4 -2
- package/dist/client/runtime.js +3 -0
- package/dist/client/seen.js +9 -7
- package/dist/client/sheet.js +64 -4
- package/dist/client/words.d.ts +10 -1
- package/dist/client/words.js +11 -2
- package/dist/contract.d.mts +32 -22
- package/dist/contract.mjs +127 -37
- package/dist/github/api.d.ts +100 -0
- package/dist/github/api.js +164 -0
- package/dist/github/compose.d.ts +152 -0
- package/dist/github/compose.js +309 -0
- package/dist/github/filer.d.ts +72 -0
- package/dist/github/filer.js +89 -0
- package/dist/github/hosts.d.ts +41 -0
- package/dist/github/hosts.js +154 -0
- package/dist/github/index.d.ts +12 -0
- package/dist/github/index.js +9 -0
- package/dist/github/privacy.d.ts +34 -0
- package/dist/github/privacy.js +142 -0
- package/dist/github/relay.d.ts +159 -0
- package/dist/github/relay.js +734 -0
- package/dist/server/geo.d.ts +17 -0
- package/dist/server/geo.js +35 -0
- package/dist/server/handlers.d.ts +54 -3
- package/dist/server/handlers.js +143 -41
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/package.json +5 -1
- package/src/client/adapters.ts +18 -1
- package/src/client/context.ts +243 -0
- package/src/client/detail.tsx +13 -8
- package/src/client/entry.tsx +2 -1
- package/src/client/index.ts +2 -0
- package/src/client/mine.tsx +7 -3
- package/src/client/runtime.ts +3 -0
- package/src/client/seen.ts +8 -6
- package/src/client/sheet.tsx +78 -4
- package/src/client/styles.css +5 -0
- package/src/client/words.ts +15 -2
- package/src/contract.mjs +110 -37
- package/src/github/api.ts +169 -0
- package/src/github/compose.ts +354 -0
- package/src/github/filer.ts +96 -0
- package/src/github/hosts.ts +117 -0
- package/src/github/index.ts +15 -0
- package/src/github/privacy.ts +131 -0
- package/src/github/relay.ts +637 -0
- package/src/server/geo.ts +32 -0
- package/src/server/handlers.ts +158 -45
- package/src/server/index.ts +3 -1
package/dist/client/sheet.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first. The kind is one
|
|
4
4
|
// small menu and saying it is one big box for every kind, broken included: the box is what takes the space,
|
|
5
5
|
// so a person sees first that they will be heard out (the operator, 2026-10-19). Everything
|
|
@@ -7,6 +7,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
// through the outbox, so a closed app loses nothing. A voice note alone is a whole report.
|
|
8
8
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { isAcceptedMedia, sanitizeRoute, typeForFile, validateReport } from '../contract.mjs';
|
|
10
|
+
import { destinationWords, whatGoes } from '../github/compose.js';
|
|
11
|
+
import { validContact } from '../contract.mjs';
|
|
12
|
+
import { collectContext } from './context.js';
|
|
10
13
|
import { useDialog } from './dialog.js';
|
|
11
14
|
import { DefaultField } from './default-field.js';
|
|
12
15
|
import { draftKey, loadDraft, saveDraft } from './drafts.js';
|
|
@@ -77,6 +80,39 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
77
80
|
const [announce, setAnnounce] = useState('');
|
|
78
81
|
const [sent, setSent] = useState(null);
|
|
79
82
|
const [loaded, setLoaded] = useState(false);
|
|
83
|
+
const [dest, setDest] = useState(null);
|
|
84
|
+
// Everything shared so it can be fixed, each with why; all on by default, any one can be left out (`choice`).
|
|
85
|
+
const [ctx, setCtx] = useState(null);
|
|
86
|
+
const [contact, setContact] = useState(null);
|
|
87
|
+
const [choice, setChoice] = useState({});
|
|
88
|
+
// Where it files, asked once when the sheet opens; until it answers (or with none) the one-line note stays.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!adapters.destination)
|
|
91
|
+
return;
|
|
92
|
+
let live = true;
|
|
93
|
+
adapters.destination().then((d) => { if (live)
|
|
94
|
+
setDest(d && typeof d.repo === 'string' ? d : null); }, () => { if (live)
|
|
95
|
+
setDest(null); });
|
|
96
|
+
return () => { live = false; };
|
|
97
|
+
}, [adapters]);
|
|
98
|
+
const contextOptions = useCallback(() => {
|
|
99
|
+
const c = adapters.context;
|
|
100
|
+
return typeof c === 'function' ? { extra: c, precise: false } : { extra: c?.items ? () => c.items() : undefined, precise: Boolean(c?.precise) };
|
|
101
|
+
}, [adapters]);
|
|
102
|
+
const gather = useCallback(() => collectContext({ identity: who ?? null, where, about: adapters.about, ...contextOptions(), ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) }), [who, where, adapters, contextOptions]);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!who)
|
|
105
|
+
return;
|
|
106
|
+
let live = true;
|
|
107
|
+
void gather().then((c) => { if (live)
|
|
108
|
+
setCtx(c); });
|
|
109
|
+
if (adapters.contact)
|
|
110
|
+
adapters.contact().then((c) => { if (live)
|
|
111
|
+
setContact(c && !validContact(c).length ? c : null); }, () => { });
|
|
112
|
+
return () => { live = false; };
|
|
113
|
+
// where is an object the host may rebuild each render; the context is gathered once per open and again at Send.
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [adapters, who]);
|
|
80
116
|
const chain = useRef(Promise.resolve());
|
|
81
117
|
// What is on the sheet, kept in step with state so a second take or picture added a moment later counts the first.
|
|
82
118
|
const voiceRef = useRef([]);
|
|
@@ -268,6 +304,22 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
268
304
|
].filter((d) => Boolean(d.value));
|
|
269
305
|
const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)];
|
|
270
306
|
const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`;
|
|
307
|
+
// With a destination, the one line becomes the list of exactly what the issue will carry, built by the same function
|
|
308
|
+
// the issue is written from (`whatGoes`), so the list and the issue cannot drift. It follows the draft as it changes.
|
|
309
|
+
// Every row of the list, whether or not there is a destination; optional rows carry a tick box.
|
|
310
|
+
const rows = whatGoes({
|
|
311
|
+
kind, words, voice: voice.map((v) => ({ file: v.file, ms: v.ms })), media: media.map((m) => ({ file: m.file, type: m.type, bytes: m.bytes })),
|
|
312
|
+
where: { screen: where.screen, route: sanitizeRoute(where.route) },
|
|
313
|
+
diagnostics: Object.fromEntries(avail.map((d) => [d.name, d.value])),
|
|
314
|
+
appName: adapters.about?.app ?? adapters.words.app,
|
|
315
|
+
...(dest?.name ? { filerName: dest.name } : {}),
|
|
316
|
+
...(ctx ? { context: ctx } : {}),
|
|
317
|
+
...(contact ? { who: { contact } } : {}),
|
|
318
|
+
}, dest ?? { repo: '', public: false, account: '', attaches: true });
|
|
319
|
+
const isOn = (key, fallback = true) => choice[key] ?? rows.find((r) => r.key === key)?.on ?? fallback;
|
|
320
|
+
const goes = dest ? rows : null;
|
|
321
|
+
const destWords = dest ? destinationWords(dest) : null;
|
|
322
|
+
const optionalRow = (g) => (_jsx("li", { "data-goes": g.key, className: "sac-goes-opt", children: _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: isOn(g.key), onChange: (e) => setChoice((c) => ({ ...c, [g.key]: e.target.checked })) }), ' ', _jsx("span", { className: "sac-goes-label", children: g.text }), g.value ? _jsxs(_Fragment, { children: [": ", _jsx("span", { className: "sac-goes-value", children: g.value })] }) : null, ' ', _jsxs("span", { className: "sac-muted sac-goes-why", children: ["(", g.why, g.reason ? `; off by default because ${g.reason}` : '', ")"] })] }) }, g.key));
|
|
271
323
|
const hasAnything = Boolean(words.trim() || voice.length || media.length);
|
|
272
324
|
async function send() {
|
|
273
325
|
if (!rt || !who || !key || !hasAnything)
|
|
@@ -275,14 +327,22 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
275
327
|
setError('');
|
|
276
328
|
await chain.current;
|
|
277
329
|
const d = await saveDraft(rt.store, key, { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] });
|
|
330
|
+
// What was left out never leaves the phone: the context gathered again now (the trail has grown), minus every row
|
|
331
|
+
// the person unticked, and the diagnostics those rows stand for.
|
|
332
|
+
const fresh = await gather();
|
|
333
|
+
const keep = { ...fresh, items: fresh.items.filter((i) => isOn(`ctx:${i.key}`)), routes: isOn('routes') ? fresh.routes : [], taps: isOn('taps') ? fresh.taps : [], errors: isOn('errors') ? fresh.errors : [] };
|
|
334
|
+
const leftOut = { 'ctx:device': 'device', 'ctx:screen': 'screen', 'ctx:time': 'at' };
|
|
278
335
|
const diagnostics = {};
|
|
279
336
|
for (const x of avail)
|
|
280
|
-
|
|
337
|
+
if (!Object.entries(leftOut).some(([k, d]) => d === x.name && !isOn(k)))
|
|
338
|
+
diagnostics[x.name] = x.value;
|
|
339
|
+
const reach = contact && isOn('contact') ? contact : null;
|
|
281
340
|
const report = {
|
|
282
341
|
id: d.reportId,
|
|
283
342
|
kind,
|
|
284
343
|
...(adapters.about ? { app: Object.fromEntries(Object.entries(adapters.about).filter(([k]) => k !== 'version')) } : {}),
|
|
285
|
-
who: { member: who.account, role: who.role },
|
|
344
|
+
who: { member: who.account, role: who.role, ...(reach ? { contact: reach } : {}) },
|
|
345
|
+
context: keep,
|
|
286
346
|
words: words.trim(),
|
|
287
347
|
voice: voice.map((v) => ({ file: v.file, ms: v.ms, field: v.field })),
|
|
288
348
|
media: await Promise.all(media.map(async (m) => {
|
|
@@ -334,5 +394,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
334
394
|
return (_jsx("div", { className: "sac-backdrop", "data-suggest-sheet": "", children: _jsxs("div", { ref: box, className: "sac-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabIndex: -1, children: [_jsxs("header", { className: "sac-header", children: [_jsx("h2", { id: titleId, className: "sac-title", children: S.sheetTitle }), onClose ? _jsx("button", { type: "button", className: "sac-button sac-close", onClick: onClose, children: S.close }) : null] }), _jsx("div", { className: "sac-sr", "data-sac-live": "", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announce }), who === undefined ? _jsx("p", { children: S.loading }) : who === null ? _jsx("p", { role: "alert", children: S.signedOut }) : !rt ? _jsx("p", { children: S.loading }) : sent ? (_jsxs("div", { className: "sac-sent", children: [_jsx(Receipt, { adapters: adapters, reportId: sent.id, quiet: true, onLine: setAnnounce }), sent.files.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: sent.files.map((f) => {
|
|
335
395
|
const p = rt.part(sent.id, f.file);
|
|
336
396
|
return (_jsxs("li", { className: "sac-item", "data-attachment-state": p.state, children: [_jsxs("span", { className: "sac-item-name", children: [f.name, f.bytes ? `, ${sizeWords(f.bytes)}` : ''] }), _jsxs("span", { className: "sac-item-state", children: [p.state === 'sending' ? _jsx("progress", { "aria-label": `${S.sending} ${f.name}` }) : null, p.state === 'waiting' ? S.waitingToSend : p.state === 'sending' ? S.sending : p.state === 'sent' ? S.sentWord : S.didNotSend(p.reason ?? '')] }), p.state === 'failed' ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(sent.id), children: S.retry }) : null] }, f.file));
|
|
337
|
-
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("div", { className: "sac-kind", children: [_jsx("label", { htmlFor: `${titleId}-kind`, children: S.kindLegend }), _jsx("select", { id: `${titleId}-kind`, className: "sac-select", value: kind, onChange: (e) => setKind(e.target.value), children: kinds.map((k) => _jsx("option", { value: k.id, children: k.label }, k.id)) })] }), _jsxs("fieldset", { className: "sac-group sac-sayit", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), _jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), error ? _jsx("p", { role: "alert", className: "sac-error", children: error }) : null, _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "submit", className: "sac-button sac-primary", disabled: !hasAnything, "aria-describedby": hasAnything ? undefined : `${titleId}-hint`, children: S.send }), hasAnything ? null : _jsx("span", { id: `${titleId}-hint`, className: "sac-muted", children: S.sendHint })] })] })), _jsx(Toasts, {})] }) }));
|
|
397
|
+
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("div", { className: "sac-kind", children: [_jsx("label", { htmlFor: `${titleId}-kind`, children: S.kindLegend }), _jsx("select", { id: `${titleId}-kind`, className: "sac-select", value: kind, onChange: (e) => setKind(e.target.value), children: kinds.map((k) => _jsx("option", { value: k.id, children: k.label }, k.id)) })] }), _jsxs("fieldset", { className: "sac-group sac-sayit", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), goes && destWords ? (_jsxs("div", { className: "sac-goes sac-fyi", "data-sac-goes": "", children: [_jsxs("p", { className: "sac-goes-head", children: [_jsx("strong", { children: destWords.headline }), ' (', _jsx("span", { className: destWords.danger ? 'sac-danger' : 'sac-muted', children: destWords.privacy }), ")"] }), _jsx("p", { className: "sac-muted", children: "It will carry:" }), _jsx("ul", { className: "sac-goes-list", children: goes.filter((g) => !g.optional).map((g) => _jsx("li", { "data-goes": g.key, children: g.text }, g.key)) }), goes.some((g) => g.optional) ? _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: goes.filter((g) => g.optional).map(optionalRow) }) : null, _jsx("p", { className: "sac-muted", children: destWords.account })] })) : (_jsxs("div", { className: "sac-fyi", children: [_jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), rows.some((g) => g.optional) ? _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: rows.filter((g) => g.optional).map(optionalRow) }) : null] })), error ? _jsx("p", { role: "alert", className: "sac-error", children: error }) : null, _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "submit", className: "sac-button sac-primary", disabled: !hasAnything, "aria-describedby": hasAnything ? undefined : `${titleId}-hint`, children: S.send }), hasAnything ? null : _jsx("span", { id: `${titleId}-hint`, className: "sac-muted", children: S.sendHint })] })] })), _jsx(Toasts, {})] }) }));
|
|
338
398
|
}
|
package/dist/client/words.d.ts
CHANGED
|
@@ -99,12 +99,21 @@ export interface Strings {
|
|
|
99
99
|
entryMoved: string;
|
|
100
100
|
/** The one toast per open. `words` is the status's own word (wordsFor); `note` is the not-now reason, when there is one. */
|
|
101
101
|
movedToast: (words: string, note?: string) => string;
|
|
102
|
+
/** The link to the issue a report became. */
|
|
103
|
+
issueLink: (n: number) => string;
|
|
104
|
+
/** The accessible name of the list of context, each row a tick box. */
|
|
105
|
+
contextLegend: string;
|
|
102
106
|
}
|
|
103
107
|
export declare const DEFAULT_STRINGS: Strings;
|
|
104
108
|
export declare function stringsFor(words: Words): Strings;
|
|
105
109
|
/** The design's status word for a status; a status the contract does not know shows as itself instead of taking the list down. */
|
|
106
110
|
export declare function statusWords(status: string, owner: string): string;
|
|
107
|
-
export declare function statusMeaning(status: string, owner: string): string;
|
|
111
|
+
export declare function statusMeaning(status: string, owner: string, issue?: unknown): string;
|
|
112
|
+
/** The issue a report became, when it is one this module will link: a github.com address and a whole number. */
|
|
113
|
+
export declare function linkableIssue(issue: unknown): {
|
|
114
|
+
number: number;
|
|
115
|
+
url: string;
|
|
116
|
+
} | null;
|
|
108
117
|
export type ReceiptState = 'on-phone' | 'received' | 'on-mac';
|
|
109
118
|
/** The receipt's one line (design section 2). `received` is only ever asked for after the adapter said `done`. */
|
|
110
119
|
export declare function receiptWords(state: ReceiptState, words: Pick<Words, 'owner'>): string;
|
package/dist/client/words.js
CHANGED
|
@@ -89,6 +89,8 @@ export const DEFAULT_STRINGS = {
|
|
|
89
89
|
newMark: 'New',
|
|
90
90
|
entryMoved: 'One of your suggestions has news',
|
|
91
91
|
movedToast: (words, note) => (note ? `Your suggestion: ${words}. ${note}` : `Your suggestion: ${words}`),
|
|
92
|
+
issueLink: (n) => `Issue #${n}`,
|
|
93
|
+
contextLegend: 'Also shared, so it can be fixed (untick anything to leave it out)',
|
|
92
94
|
};
|
|
93
95
|
export function stringsFor(words) {
|
|
94
96
|
return { ...DEFAULT_STRINGS, ...words.text };
|
|
@@ -107,14 +109,21 @@ export function statusWords(status, owner) {
|
|
|
107
109
|
return String(status);
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
|
-
export function statusMeaning(status, owner) {
|
|
112
|
+
export function statusMeaning(status, owner, issue) {
|
|
111
113
|
try {
|
|
112
|
-
return meaningFor(status, { owner });
|
|
114
|
+
return meaningFor(status, { owner, issue });
|
|
113
115
|
}
|
|
114
116
|
catch {
|
|
115
117
|
return '';
|
|
116
118
|
}
|
|
117
119
|
}
|
|
120
|
+
/** The issue a report became, when it is one this module will link: a github.com address and a whole number. */
|
|
121
|
+
export function linkableIssue(issue) {
|
|
122
|
+
const i = issue;
|
|
123
|
+
if (!i || typeof i !== 'object' || !Number.isInteger(i.number) || typeof i.url !== 'string' || !i.url.startsWith('https://github.com/'))
|
|
124
|
+
return null;
|
|
125
|
+
return { number: i.number, url: i.url };
|
|
126
|
+
}
|
|
118
127
|
/** The receipt's one line (design section 2). `received` is only ever asked for after the adapter said `done`. */
|
|
119
128
|
export function receiptWords(state, words) {
|
|
120
129
|
if (state === 'received')
|
package/dist/contract.d.mts
CHANGED
|
@@ -22,42 +22,52 @@ export declare const FLOW: {
|
|
|
22
22
|
actor: string;
|
|
23
23
|
next: string[];
|
|
24
24
|
};
|
|
25
|
-
|
|
25
|
+
held: {
|
|
26
26
|
actor: string;
|
|
27
27
|
next: string[];
|
|
28
28
|
};
|
|
29
|
-
|
|
29
|
+
open: {
|
|
30
30
|
actor: string;
|
|
31
31
|
next: string[];
|
|
32
32
|
};
|
|
33
|
-
|
|
33
|
+
done: {
|
|
34
34
|
actor: string;
|
|
35
35
|
next: string[];
|
|
36
36
|
};
|
|
37
|
-
"
|
|
37
|
+
"not-planned": {
|
|
38
38
|
actor: string;
|
|
39
39
|
next: string[];
|
|
40
40
|
};
|
|
41
|
-
live: {
|
|
42
|
-
actor: string;
|
|
43
|
-
next: never[];
|
|
44
|
-
};
|
|
45
|
-
"sent-to-template": {
|
|
46
|
-
actor: string;
|
|
47
|
-
next: never[];
|
|
48
|
-
};
|
|
49
|
-
filed: {
|
|
50
|
-
actor: string;
|
|
51
|
-
next: never[];
|
|
52
|
-
};
|
|
53
|
-
"not-now": {
|
|
54
|
-
actor: string;
|
|
55
|
-
next: never[];
|
|
56
|
-
};
|
|
57
41
|
};
|
|
42
|
+
export declare const LEGACY: {
|
|
43
|
+
"with-freeda": string;
|
|
44
|
+
planned: string;
|
|
45
|
+
"waiting-on-owner": string;
|
|
46
|
+
"being-made": string;
|
|
47
|
+
live: string;
|
|
48
|
+
"sent-to-template": string;
|
|
49
|
+
filed: string;
|
|
50
|
+
"not-now": string;
|
|
51
|
+
};
|
|
52
|
+
/** The status this one means now: a legacy status folded, anything else as it is. */
|
|
53
|
+
export declare const currentStatus: (s: any) => any;
|
|
58
54
|
export declare const mayMove: (from: any, to: any) => boolean;
|
|
59
55
|
export declare const wordsFor: (status: any, { owner }?: {}) => any;
|
|
60
|
-
export declare const meaningFor: (status: any, { owner }?: {}) => any;
|
|
56
|
+
export declare const meaningFor: (status: any, { owner, issue }?: {}) => any;
|
|
57
|
+
export declare function validContact(c: any): string[];
|
|
58
|
+
export declare const CONTEXT_CEILINGS: {
|
|
59
|
+
items: number;
|
|
60
|
+
routes: number;
|
|
61
|
+
taps: number;
|
|
62
|
+
errors: number;
|
|
63
|
+
};
|
|
64
|
+
export declare const isIsoTime: (v: any) => boolean;
|
|
65
|
+
export declare function validContext(ctx: any): any[];
|
|
66
|
+
/** A GitHub "owner/name": two plain segments, neither "." nor "..", so it can never walk out of /repos/ in a URL.
|
|
67
|
+
* @param {unknown} s @returns {s is string} */
|
|
68
|
+
export declare const isRepoName: (s: unknown) => s is string;
|
|
69
|
+
/** The issue a report became: `{ repo: "owner/name", number, url }`, the url on github.com. Problems as a list. */
|
|
70
|
+
export declare function validIssue(issue: any): string[];
|
|
61
71
|
declare const randomSix: () => string;
|
|
62
72
|
export declare function newReportId(now?: Date, rand?: typeof randomSix): string;
|
|
63
73
|
export declare const sanitizeRoute: (url: any) => string;
|
|
@@ -73,7 +83,7 @@ export declare function validateReport(raw: any, { ceilings }?: {
|
|
|
73
83
|
voiceTotalMs: number;
|
|
74
84
|
perPersonPerDay: number;
|
|
75
85
|
} | undefined;
|
|
76
|
-
}):
|
|
86
|
+
}): any[];
|
|
77
87
|
export declare const ACCEPTED_TYPES: string[];
|
|
78
88
|
/** A declared type without its parameters (`audio/webm;codecs=opus`), lowercased, with common aliases folded. */
|
|
79
89
|
export declare const canonicalType: (t: any) => any;
|
package/dist/contract.mjs
CHANGED
|
@@ -11,52 +11,132 @@ export const CEILINGS = {
|
|
|
11
11
|
voiceMs: 300_000, voiceTotalMs: 900_000,
|
|
12
12
|
perPersonPerDay: 20,
|
|
13
13
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// the
|
|
20
|
-
|
|
14
|
+
// A suggestion becomes one ordinary GitHub issue (suggestions-as-github-issues design, section 3), so the statuses a
|
|
15
|
+
// person sees are GitHub's: not sent, received, held before filing, then the issue's own open and closed states.
|
|
16
|
+
export const STATUSES = ["on-phone", "received", "held", "open", "done", "not-planned"];
|
|
17
|
+
// Closed on GitHub. A closed issue can be reopened, so an end is not final: it can go back to "open".
|
|
18
|
+
export const END = ["done", "not-planned"];
|
|
19
|
+
// The moves the person who suggested a change hears about: the issue existing, and every close.
|
|
20
|
+
export const TOLD = ["open", ...END];
|
|
21
|
+
// actor: who PERFORMS this step. Sent by the person, received by the app, held for the owner (the privacy catch stopped
|
|
22
|
+
// it, or the account cannot file yet), filed by the Mac (or an app's own server), closed or reopened on GitHub. A Helm on
|
|
23
|
+
// the owner's Mac posts straight there, so a report may go from the phone to held or open without the app's "received".
|
|
21
24
|
export const FLOW = {
|
|
22
|
-
"on-phone": { actor: "person", next: ["received", "
|
|
23
|
-
"received": { actor: "app", next: ["
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"live": { actor: "freeda", next: [] },
|
|
29
|
-
"sent-to-template": { actor: "freeda", next: [] },
|
|
30
|
-
"filed": { actor: "freeda", next: [] },
|
|
31
|
-
"not-now": { actor: "owner", next: [] },
|
|
25
|
+
"on-phone": { actor: "person", next: ["received", "held", "open"] },
|
|
26
|
+
"received": { actor: "app", next: ["held", "open", "not-planned"] },
|
|
27
|
+
"held": { actor: "owner", next: ["open", "not-planned"] },
|
|
28
|
+
"open": { actor: "mac", next: ["done", "not-planned"] },
|
|
29
|
+
"done": { actor: "github", next: ["open"] },
|
|
30
|
+
"not-planned": { actor: "github", next: ["open"] },
|
|
32
31
|
};
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
32
|
+
// THE OLD STATUSES (0.1.x) STAY READABLE. Each folds into what it now means: a report the old pipeline was still
|
|
33
|
+
// working on has not been filed yet (the Mac files it), one it shipped or passed on is done, one declined is not planned.
|
|
34
|
+
export const LEGACY = {
|
|
35
|
+
"with-freeda": "received",
|
|
36
|
+
"planned": "received",
|
|
37
|
+
"waiting-on-owner": "received",
|
|
38
|
+
"being-made": "received",
|
|
39
|
+
"live": "done",
|
|
40
|
+
"sent-to-template": "done",
|
|
41
|
+
"filed": "done",
|
|
42
|
+
"not-now": "not-planned",
|
|
43
|
+
};
|
|
44
|
+
/** The status this one means now: a legacy status folded, anything else as it is. */
|
|
45
|
+
export const currentStatus = (s) => LEGACY[s] ?? s;
|
|
46
|
+
export const mayMove = (from, to) => Boolean(FLOW[currentStatus(from)]?.next.includes(to));
|
|
47
|
+
// The design's status table (section 3), word for word.
|
|
48
|
+
const issueWords = (issue) => (Number.isInteger(issue?.number) ? `Filed as issue #${issue.number}` : "Filed as an issue");
|
|
36
49
|
const TABLE = {
|
|
37
|
-
"on-phone": ["On this phone", () => "Not sent yet; it will send when there is a connection"],
|
|
38
|
-
"received": ["Received", () => "The app has it, safely stored"],
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"live": ["Done", () => "Shipped in the app, with the date it went live"],
|
|
44
|
-
"sent-to-template": ["Sent to the template", () => "It became a change to the template every copy of this app uses; it reaches this app once merged"],
|
|
45
|
-
"filed": ["Filed", () => "It was passed to whoever owns the problem"],
|
|
46
|
-
"not-now": ["Not now", (o) => `${o} decided against it, with the one-line reason if given`],
|
|
50
|
+
"on-phone": [() => "On this phone", () => "Not sent yet; it will send when there is a connection"],
|
|
51
|
+
"received": [() => "Received", () => "The app has it, safely stored"],
|
|
52
|
+
"held": [(o) => `Waiting on ${o}`, (o) => `${o} looks at it before it is filed`],
|
|
53
|
+
"open": [() => "Open", (o, issue) => issueWords(issue)],
|
|
54
|
+
"done": [() => "Done", () => "Closed as completed"],
|
|
55
|
+
"not-planned": [() => "Not planned", () => "Closed as not planned, with the reason if one was given"],
|
|
47
56
|
};
|
|
48
57
|
const row = (status) => {
|
|
49
|
-
|
|
58
|
+
const s = currentStatus(status);
|
|
59
|
+
if (!TABLE[s])
|
|
50
60
|
throw new Error(`${status} is not a status`);
|
|
51
|
-
return TABLE[
|
|
61
|
+
return TABLE[s];
|
|
52
62
|
};
|
|
53
|
-
export const wordsFor = (status, { owner } = {}) => row(status)[0]
|
|
54
|
-
export const meaningFor = (status, { owner } = {}) => row(status)[1](owner);
|
|
63
|
+
export const wordsFor = (status, { owner } = {}) => row(status)[0](owner);
|
|
64
|
+
export const meaningFor = (status, { owner, issue } = {}) => row(status)[1](owner, issue);
|
|
65
|
+
// HOW TO REACH THE SENDER: what the app already knows of them, so whoever fixes it can say thanks or ask a question.
|
|
66
|
+
const CONTACT_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
67
|
+
const CONTACT_PHONE = /^\+?[0-9][0-9 ().-]{5,24}$/;
|
|
68
|
+
export function validContact(c) {
|
|
69
|
+
if (!c || typeof c !== "object" || Array.isArray(c))
|
|
70
|
+
return ["who.contact is not an object"];
|
|
71
|
+
const errs = [];
|
|
72
|
+
for (const k of Object.keys(c))
|
|
73
|
+
if (!["email", "phone", "handle"].includes(k))
|
|
74
|
+
errs.push(`who.contact.${k} is not one of email, phone, handle`);
|
|
75
|
+
if (c.email !== undefined && (typeof c.email !== "string" || c.email.length > 254 || !CONTACT_EMAIL.test(c.email)))
|
|
76
|
+
errs.push("who.contact.email is not an email address");
|
|
77
|
+
if (c.phone !== undefined && (typeof c.phone !== "string" || !CONTACT_PHONE.test(c.phone)))
|
|
78
|
+
errs.push("who.contact.phone is not a phone number");
|
|
79
|
+
if (c.handle !== undefined && (typeof c.handle !== "string" || !c.handle.trim() || c.handle.length > 100 || /[\r\n]/.test(c.handle)))
|
|
80
|
+
errs.push("who.contact.handle is not a short one-line handle");
|
|
81
|
+
return errs;
|
|
82
|
+
}
|
|
83
|
+
// THE CONTEXT a report carries so it can be fixed: labelled items, each with why it is shared, and the trail that led here.
|
|
84
|
+
export const CONTEXT_CEILINGS = { items: 40, routes: 10, taps: 20, errors: 10 };
|
|
85
|
+
const shortText = (v, max) => typeof v === "string" && v.length <= max;
|
|
86
|
+
/** A time as the module writes one: ISO 8601 with a zone, at most 40 characters, so a time field can never carry text. */
|
|
87
|
+
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
88
|
+
export const isIsoTime = (v) => typeof v === "string" && v.length <= 40 && ISO_TIME.test(v);
|
|
89
|
+
export function validContext(ctx) {
|
|
90
|
+
if (!ctx || typeof ctx !== "object" || Array.isArray(ctx))
|
|
91
|
+
return ["context is not an object"];
|
|
92
|
+
const errs = [];
|
|
93
|
+
const list = (name, max, check) => {
|
|
94
|
+
const v = ctx[name];
|
|
95
|
+
if (v === undefined)
|
|
96
|
+
return;
|
|
97
|
+
if (!Array.isArray(v)) {
|
|
98
|
+
errs.push(`context.${name} is not a list`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (v.length > max)
|
|
102
|
+
errs.push(`context.${name} has ${v.length} entries; the ceiling is ${max}`);
|
|
103
|
+
v.forEach((x, i) => { const e = x && typeof x === "object" ? check(x) : "is not an object"; if (e)
|
|
104
|
+
errs.push(`context.${name}.${i} ${e}`); });
|
|
105
|
+
};
|
|
106
|
+
list("items", CONTEXT_CEILINGS.items, (x) => !shortText(x.key, 40) || !/^[A-Za-z0-9:_-]+$/.test(x.key) ? "has no plain key"
|
|
107
|
+
: !shortText(x.label, 80) || !x.label ? "has no short label"
|
|
108
|
+
: !shortText(x.value, 500) ? "has no value of at most 500 characters"
|
|
109
|
+
: !shortText(x.why, 200) || !x.why.trim() ? "does not say why it is shared" : null);
|
|
110
|
+
list("routes", CONTEXT_CEILINGS.routes, (x) => (!shortText(x.route, 300) || /[?#]/.test(x.route) ? "is not a route without its query" : !isIsoTime(x.at) ? "has no ISO time" : null));
|
|
111
|
+
list("taps", CONTEXT_CEILINGS.taps, (x) => (!shortText(x.label, 80) ? "has no label of at most 80 characters" : !isIsoTime(x.at) ? "has no ISO time" : null));
|
|
112
|
+
list("errors", CONTEXT_CEILINGS.errors, (x) => (!shortText(x.message, 300) ? "has no message of at most 300 characters" : x.frame !== undefined && !shortText(x.frame, 200) ? "has a frame over 200 characters" : !isIsoTime(x.at) ? "has no ISO time" : null));
|
|
113
|
+
return errs;
|
|
114
|
+
}
|
|
115
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
116
|
+
/** A GitHub "owner/name": two plain segments, neither "." nor "..", so it can never walk out of /repos/ in a URL.
|
|
117
|
+
* @param {unknown} s @returns {s is string} */
|
|
118
|
+
export const isRepoName = (s) => typeof s === "string" && REPO.test(s) && !s.split("/").some((p) => p === "." || p === "..");
|
|
119
|
+
/** The issue a report became: `{ repo: "owner/name", number, url }`, the url on github.com. Problems as a list. */
|
|
120
|
+
export function validIssue(issue) {
|
|
121
|
+
if (!issue || typeof issue !== "object" || Array.isArray(issue))
|
|
122
|
+
return ["issue is not an object"];
|
|
123
|
+
const errs = [];
|
|
124
|
+
if (!isRepoName(issue.repo))
|
|
125
|
+
errs.push("issue.repo is not owner/name");
|
|
126
|
+
if (!Number.isInteger(issue.number) || issue.number < 1)
|
|
127
|
+
errs.push("issue.number is not a positive whole number");
|
|
128
|
+
if (typeof issue.url !== "string" || !issue.url.startsWith("https://github.com/"))
|
|
129
|
+
errs.push("issue.url is not a https://github.com/ link");
|
|
130
|
+
return errs;
|
|
131
|
+
}
|
|
55
132
|
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
133
|
+
// The platform's secure random source where there is one (every browser and Node 19+), Math.random only where there is not.
|
|
56
134
|
const randomSix = () => {
|
|
135
|
+
const c = globalThis.crypto;
|
|
136
|
+
const bytes = typeof c?.getRandomValues === "function" ? c.getRandomValues(new Uint8Array(6)) : Uint8Array.from({ length: 6 }, () => Math.floor(Math.random() * 256));
|
|
57
137
|
let s = "";
|
|
58
|
-
for (
|
|
59
|
-
s += ALPHABET[
|
|
138
|
+
for (const b of bytes)
|
|
139
|
+
s += ALPHABET[b % ALPHABET.length];
|
|
60
140
|
return s;
|
|
61
141
|
};
|
|
62
142
|
export function newReportId(now = new Date(), rand = randomSix) {
|
|
@@ -92,10 +172,20 @@ export function validateReport(raw, { ceilings = CEILINGS } = {}) {
|
|
|
92
172
|
errs.push(`kind ${JSON.stringify(raw.kind)} is not one of ${KINDS.join(", ")}`);
|
|
93
173
|
if (!ROLES.includes(raw.who?.role))
|
|
94
174
|
errs.push(`role ${JSON.stringify(raw.who?.role)} is not one of ${ROLES.join(", ")}`);
|
|
175
|
+
// The one place an email may sit is the sender's own way to be reached, who.contact.email, and only there.
|
|
95
176
|
for (const p of findKey(raw, "email"))
|
|
96
|
-
|
|
177
|
+
if (p !== "report.who.contact.email")
|
|
178
|
+
errs.push(`${p}: a report never carries an email`);
|
|
179
|
+
if (raw.who?.contact !== undefined)
|
|
180
|
+
errs.push(...validContact(raw.who.contact));
|
|
181
|
+
if (raw.diagnostics?.at !== undefined && !isIsoTime(raw.diagnostics.at))
|
|
182
|
+
errs.push("diagnostics.at is not an ISO time");
|
|
183
|
+
if (raw.context !== undefined)
|
|
184
|
+
errs.push(...validContext(raw.context));
|
|
97
185
|
if (String(raw.where?.route ?? "").includes("?"))
|
|
98
186
|
errs.push("where.route keeps a query string");
|
|
187
|
+
if (raw.issue !== undefined)
|
|
188
|
+
errs.push(...validIssue(raw.issue));
|
|
99
189
|
for (const k of Object.keys(raw.diagnostics ?? {})) {
|
|
100
190
|
if (!DIAGNOSTIC_KEYS.includes(k))
|
|
101
191
|
errs.push(`diagnostics.${k} is not one of ${DIAGNOSTIC_KEYS.join(", ")}`);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type ComposeOptions, type Draft } from './compose.ts';
|
|
2
|
+
import type { AttachmentHost, AttachmentFile } from './hosts.ts';
|
|
3
|
+
import { type RelayFiler } from './filer.ts';
|
|
4
|
+
export type Fetch = typeof globalThis.fetch;
|
|
5
|
+
export interface GitHubConfig {
|
|
6
|
+
repo: string;
|
|
7
|
+
token: string;
|
|
8
|
+
fetch?: Fetch; /** Default https://api.github.com */
|
|
9
|
+
api?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class GitHubError extends Error {
|
|
12
|
+
status: number;
|
|
13
|
+
body: unknown;
|
|
14
|
+
constructor(message: string, status: number, body: unknown);
|
|
15
|
+
}
|
|
16
|
+
/** One REST call. Answers the parsed body, or throws a GitHubError carrying GitHub's status. */
|
|
17
|
+
export declare function github(cfg: {
|
|
18
|
+
token: string;
|
|
19
|
+
fetch?: Fetch;
|
|
20
|
+
api?: string;
|
|
21
|
+
}, method: string, path: string, body?: unknown): Promise<{
|
|
22
|
+
status: number;
|
|
23
|
+
body: any;
|
|
24
|
+
}>;
|
|
25
|
+
/** Opens one issue. Missing labels are created first; one that exists (422) or that this account may not create
|
|
26
|
+
* (403/404: a read-only filer) is skipped, and GitHub then keeps only the labels the filer may set. */
|
|
27
|
+
export declare function fileIssue(cfg: GitHubConfig, issue: {
|
|
28
|
+
title: string;
|
|
29
|
+
body: string;
|
|
30
|
+
labels: string[];
|
|
31
|
+
}): Promise<{
|
|
32
|
+
number: number;
|
|
33
|
+
url: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** Replaces an issue's body (the filer's own issue: read access is enough to edit what you opened). */
|
|
36
|
+
export declare function updateIssue(cfg: GitHubConfig, number: number, change: {
|
|
37
|
+
body: string;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
/** A follow-up the person added later, as a comment on the same issue. */
|
|
40
|
+
export declare function addFollowUp(cfg: GitHubConfig, number: number, body: string): Promise<{
|
|
41
|
+
id: number;
|
|
42
|
+
url: string;
|
|
43
|
+
}>;
|
|
44
|
+
export interface IssueState {
|
|
45
|
+
repo: string;
|
|
46
|
+
number: number;
|
|
47
|
+
state: string | null;
|
|
48
|
+
stateReason: string | null; /** First line of the last comment, on a closed issue: the reason. */
|
|
49
|
+
reason?: string;
|
|
50
|
+
missing?: true;
|
|
51
|
+
}
|
|
52
|
+
/** The state of many issues in one GraphQL call (in chunks of 50). Repo names travel as variables, never in the query. */
|
|
53
|
+
export declare function issueStates(cfg: {
|
|
54
|
+
token: string;
|
|
55
|
+
fetch?: Fetch;
|
|
56
|
+
api?: string;
|
|
57
|
+
}, issues: {
|
|
58
|
+
repo: string;
|
|
59
|
+
number: number;
|
|
60
|
+
}[]): Promise<IssueState[]>;
|
|
61
|
+
/** GitHub's state onto the person's three: open, done (closed as completed), not planned (as not planned, or a duplicate). */
|
|
62
|
+
export declare function issueStatus({ state, stateReason }: {
|
|
63
|
+
state: string | null;
|
|
64
|
+
stateReason?: string | null;
|
|
65
|
+
}): 'open' | 'done' | 'not-planned';
|
|
66
|
+
export interface FileSuggestionInput {
|
|
67
|
+
/** The report (redacted already, when the privacy catch asked for it). */
|
|
68
|
+
report: Draft;
|
|
69
|
+
compose?: Omit<ComposeOptions, 'attachmentUrls' | 'pending'>;
|
|
70
|
+
/** The bytes of the report's media and voice files, by the names the report gives them. */
|
|
71
|
+
files: AttachmentFile[];
|
|
72
|
+
attachments?: AttachmentHost;
|
|
73
|
+
}
|
|
74
|
+
export interface FiledSuggestion {
|
|
75
|
+
number: number;
|
|
76
|
+
url: string;
|
|
77
|
+
attached: Record<string, string>;
|
|
78
|
+
pending: string[]; /** How it was filed: with the filer's own token, or through a suggestion relay. */
|
|
79
|
+
via: 'direct' | 'relay';
|
|
80
|
+
}
|
|
81
|
+
/** Where to file: the repo, the filer's own token when they have one, and a relay for when that token cannot file there. */
|
|
82
|
+
export type FilingConfig = Omit<GitHubConfig, 'token'> & {
|
|
83
|
+
token?: string;
|
|
84
|
+
relay?: RelayFiler; /** The app's id in the relay's own table, when filing through one. */
|
|
85
|
+
app?: string;
|
|
86
|
+
};
|
|
87
|
+
/** Saves the files the issue is still waiting for, then edits their links into the body. Call again later with the
|
|
88
|
+
* same `attached` to finish what failed: the issue keeps saying a file is coming until it is there. */
|
|
89
|
+
export declare function attachPending(cfg: GitHubConfig, input: FileSuggestionInput & {
|
|
90
|
+
number: number;
|
|
91
|
+
attached?: Record<string, string>;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
attached: Record<string, string>;
|
|
94
|
+
pending: string[];
|
|
95
|
+
}>;
|
|
96
|
+
/** The whole filing. With a token that can see the repo it files directly, in the order that never shows an incomplete
|
|
97
|
+
* issue as complete: open the issue saying what is being added (opening it is the proof the filer can file there),
|
|
98
|
+
* save each file against that issue, then edit the links in. With no token, or one that cannot see the repo, and a
|
|
99
|
+
* `relay`, the relay files it (the files, then the issue, as its App) for someone with no access at all. */
|
|
100
|
+
export declare function fileSuggestion(cfg: FilingConfig, input: FileSuggestionInput): Promise<FiledSuggestion>;
|