@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +162 -7
  3. package/dist/client/adapters.d.ts +27 -0
  4. package/dist/client/context.d.ts +61 -0
  5. package/dist/client/context.js +279 -0
  6. package/dist/client/detail.d.ts +2 -2
  7. package/dist/client/detail.js +12 -8
  8. package/dist/client/entry.js +2 -1
  9. package/dist/client/index.d.ts +2 -0
  10. package/dist/client/index.js +1 -0
  11. package/dist/client/mine.js +4 -2
  12. package/dist/client/runtime.js +3 -0
  13. package/dist/client/seen.js +9 -7
  14. package/dist/client/sheet.js +64 -4
  15. package/dist/client/words.d.ts +10 -1
  16. package/dist/client/words.js +11 -2
  17. package/dist/contract.d.mts +32 -22
  18. package/dist/contract.mjs +127 -37
  19. package/dist/github/api.d.ts +100 -0
  20. package/dist/github/api.js +164 -0
  21. package/dist/github/compose.d.ts +152 -0
  22. package/dist/github/compose.js +309 -0
  23. package/dist/github/filer.d.ts +72 -0
  24. package/dist/github/filer.js +89 -0
  25. package/dist/github/hosts.d.ts +41 -0
  26. package/dist/github/hosts.js +154 -0
  27. package/dist/github/index.d.ts +12 -0
  28. package/dist/github/index.js +9 -0
  29. package/dist/github/privacy.d.ts +34 -0
  30. package/dist/github/privacy.js +142 -0
  31. package/dist/github/relay.d.ts +159 -0
  32. package/dist/github/relay.js +734 -0
  33. package/dist/server/geo.d.ts +17 -0
  34. package/dist/server/geo.js +35 -0
  35. package/dist/server/handlers.d.ts +54 -3
  36. package/dist/server/handlers.js +143 -41
  37. package/dist/server/index.d.ts +3 -1
  38. package/dist/server/index.js +1 -0
  39. package/package.json +5 -1
  40. package/src/client/adapters.ts +18 -1
  41. package/src/client/context.ts +243 -0
  42. package/src/client/detail.tsx +13 -8
  43. package/src/client/entry.tsx +2 -1
  44. package/src/client/index.ts +2 -0
  45. package/src/client/mine.tsx +7 -3
  46. package/src/client/runtime.ts +3 -0
  47. package/src/client/seen.ts +8 -6
  48. package/src/client/sheet.tsx +78 -4
  49. package/src/client/styles.css +5 -0
  50. package/src/client/words.ts +15 -2
  51. package/src/contract.mjs +110 -37
  52. package/src/github/api.ts +169 -0
  53. package/src/github/compose.ts +354 -0
  54. package/src/github/filer.ts +96 -0
  55. package/src/github/hosts.ts +117 -0
  56. package/src/github/index.ts +15 -0
  57. package/src/github/privacy.ts +131 -0
  58. package/src/github/relay.ts +637 -0
  59. package/src/server/geo.ts +32 -0
  60. package/src/server/handlers.ts +158 -45
  61. package/src/server/index.ts +3 -1
package/src/contract.mjs CHANGED
@@ -14,54 +14,122 @@ export const CEILINGS = {
14
14
  perPersonPerDay: 20,
15
15
  };
16
16
 
17
- export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
18
- export const END = ["live", "sent-to-template", "filed", "not-now"];
19
- // The moves the person who suggested a change hears about: the work starting, and every end. The planning steps stay quiet.
20
- export const TOLD = ["being-made", ...END];
21
-
22
- // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
23
- // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
24
- // ("not-now") before Pluma has a plan or before she asks: a report she cannot plan would otherwise have no exit.
17
+ // A suggestion becomes one ordinary GitHub issue (suggestions-as-github-issues design, section 3), so the statuses a
18
+ // person sees are GitHub's: not sent, received, held before filing, then the issue's own open and closed states.
19
+ export const STATUSES = ["on-phone", "received", "held", "open", "done", "not-planned"];
20
+ // Closed on GitHub. A closed issue can be reopened, so an end is not final: it can go back to "open".
21
+ export const END = ["done", "not-planned"];
22
+ // The moves the person who suggested a change hears about: the issue existing, and every close.
23
+ export const TOLD = ["open", ...END];
24
+
25
+ // actor: who PERFORMS this step. Sent by the person, received by the app, held for the owner (the privacy catch stopped
26
+ // 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
27
+ // the owner's Mac posts straight there, so a report may go from the phone to held or open without the app's "received".
25
28
  export const FLOW = {
26
- "on-phone": { actor: "person", next: ["received", "with-freeda"] },
27
- "received": { actor: "app", next: ["with-freeda"] },
28
- "with-freeda": { actor: "mac", next: ["planned", "not-now"] },
29
- "planned": { actor: "freeda", next: ["waiting-on-owner", "being-made", "not-now"] },
30
- "waiting-on-owner": { actor: "owner", next: ["being-made", "filed", "not-now"] },
31
- "being-made": { actor: "freeda", next: ["live", "sent-to-template", "filed", "not-now"] },
32
- "live": { actor: "freeda", next: [] },
33
- "sent-to-template": { actor: "freeda", next: [] },
34
- "filed": { actor: "freeda", next: [] },
35
- "not-now": { actor: "owner", next: [] },
29
+ "on-phone": { actor: "person", next: ["received", "held", "open"] },
30
+ "received": { actor: "app", next: ["held", "open", "not-planned"] },
31
+ "held": { actor: "owner", next: ["open", "not-planned"] },
32
+ "open": { actor: "mac", next: ["done", "not-planned"] },
33
+ "done": { actor: "github", next: ["open"] },
34
+ "not-planned": { actor: "github", next: ["open"] },
35
+ };
36
+
37
+ // THE OLD STATUSES (0.1.x) STAY READABLE. Each folds into what it now means: a report the old pipeline was still
38
+ // working on has not been filed yet (the Mac files it), one it shipped or passed on is done, one declined is not planned.
39
+ export const LEGACY = {
40
+ "with-freeda": "received",
41
+ "planned": "received",
42
+ "waiting-on-owner": "received",
43
+ "being-made": "received",
44
+ "live": "done",
45
+ "sent-to-template": "done",
46
+ "filed": "done",
47
+ "not-now": "not-planned",
36
48
  };
49
+ /** The status this one means now: a legacy status folded, anything else as it is. */
50
+ export const currentStatus = (s) => LEGACY[s] ?? s;
37
51
 
38
- export const mayMove = (from, to) => Boolean(FLOW[from]?.next.includes(to));
52
+ export const mayMove = (from, to) => Boolean(FLOW[currentStatus(from)]?.next.includes(to));
39
53
 
40
- // The design's status table (section 2), word for word. "planned" is a step inside Pluma's work
41
- // that the table does not name for a person, so it reads as "With Pluma".
54
+ // The design's status table (section 3), word for word.
55
+ const issueWords = (issue) => (Number.isInteger(issue?.number) ? `Filed as issue #${issue.number}` : "Filed as an issue");
42
56
  const TABLE = {
43
- "on-phone": ["On this phone", () => "Not sent yet; it will send when there is a connection"],
44
- "received": ["Received", () => "The app has it, safely stored"],
45
- "with-freeda": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
46
- "planned": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
47
- "waiting-on-owner": [null, (o) => `Pluma has a plan and is asking ${o}`],
48
- "being-made": ["Being made", () => "A session is working on it; the current step shows underneath"],
49
- "live": ["Done", () => "Shipped in the app, with the date it went live"],
50
- "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"],
51
- "filed": ["Filed", () => "It was passed to whoever owns the problem"],
52
- "not-now": ["Not now", (o) => `${o} decided against it, with the one-line reason if given`],
57
+ "on-phone": [() => "On this phone", () => "Not sent yet; it will send when there is a connection"],
58
+ "received": [() => "Received", () => "The app has it, safely stored"],
59
+ "held": [(o) => `Waiting on ${o}`, (o) => `${o} looks at it before it is filed`],
60
+ "open": [() => "Open", (o, issue) => issueWords(issue)],
61
+ "done": [() => "Done", () => "Closed as completed"],
62
+ "not-planned": [() => "Not planned", () => "Closed as not planned, with the reason if one was given"],
53
63
  };
54
64
  const row = (status) => {
55
- if (!TABLE[status]) throw new Error(`${status} is not a status`);
56
- return TABLE[status];
65
+ const s = currentStatus(status);
66
+ if (!TABLE[s]) throw new Error(`${status} is not a status`);
67
+ return TABLE[s];
57
68
  };
58
- export const wordsFor = (status, { owner } = {}) => row(status)[0] ?? `Waiting on ${owner}`;
59
- export const meaningFor = (status, { owner } = {}) => row(status)[1](owner);
69
+ export const wordsFor = (status, { owner } = {}) => row(status)[0](owner);
70
+ export const meaningFor = (status, { owner, issue } = {}) => row(status)[1](owner, issue);
71
+
72
+ // HOW TO REACH THE SENDER: what the app already knows of them, so whoever fixes it can say thanks or ask a question.
73
+ const CONTACT_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
74
+ const CONTACT_PHONE = /^\+?[0-9][0-9 ().-]{5,24}$/;
75
+ export function validContact(c) {
76
+ if (!c || typeof c !== "object" || Array.isArray(c)) return ["who.contact is not an object"];
77
+ const errs = [];
78
+ for (const k of Object.keys(c)) if (!["email", "phone", "handle"].includes(k)) errs.push(`who.contact.${k} is not one of email, phone, handle`);
79
+ if (c.email !== undefined && (typeof c.email !== "string" || c.email.length > 254 || !CONTACT_EMAIL.test(c.email))) errs.push("who.contact.email is not an email address");
80
+ if (c.phone !== undefined && (typeof c.phone !== "string" || !CONTACT_PHONE.test(c.phone))) errs.push("who.contact.phone is not a phone number");
81
+ if (c.handle !== undefined && (typeof c.handle !== "string" || !c.handle.trim() || c.handle.length > 100 || /[\r\n]/.test(c.handle))) errs.push("who.contact.handle is not a short one-line handle");
82
+ return errs;
83
+ }
84
+
85
+ // THE CONTEXT a report carries so it can be fixed: labelled items, each with why it is shared, and the trail that led here.
86
+ export const CONTEXT_CEILINGS = { items: 40, routes: 10, taps: 20, errors: 10 };
87
+ const shortText = (v, max) => typeof v === "string" && v.length <= max;
88
+ /** A time as the module writes one: ISO 8601 with a zone, at most 40 characters, so a time field can never carry text. */
89
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
90
+ export const isIsoTime = (v) => typeof v === "string" && v.length <= 40 && ISO_TIME.test(v);
91
+ export function validContext(ctx) {
92
+ if (!ctx || typeof ctx !== "object" || Array.isArray(ctx)) return ["context is not an object"];
93
+ const errs = [];
94
+ const list = (name, max, check) => {
95
+ const v = ctx[name];
96
+ if (v === undefined) return;
97
+ if (!Array.isArray(v)) { errs.push(`context.${name} is not a list`); return; }
98
+ if (v.length > max) errs.push(`context.${name} has ${v.length} entries; the ceiling is ${max}`);
99
+ v.forEach((x, i) => { const e = x && typeof x === "object" ? check(x) : "is not an object"; if (e) errs.push(`context.${name}.${i} ${e}`); });
100
+ };
101
+ list("items", CONTEXT_CEILINGS.items, (x) =>
102
+ !shortText(x.key, 40) || !/^[A-Za-z0-9:_-]+$/.test(x.key) ? "has no plain key"
103
+ : !shortText(x.label, 80) || !x.label ? "has no short label"
104
+ : !shortText(x.value, 500) ? "has no value of at most 500 characters"
105
+ : !shortText(x.why, 200) || !x.why.trim() ? "does not say why it is shared" : null);
106
+ 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));
107
+ 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));
108
+ 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));
109
+ return errs;
110
+ }
111
+
112
+ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
113
+ /** A GitHub "owner/name": two plain segments, neither "." nor "..", so it can never walk out of /repos/ in a URL.
114
+ * @param {unknown} s @returns {s is string} */
115
+ export const isRepoName = (s) => typeof s === "string" && REPO.test(s) && !s.split("/").some((p) => p === "." || p === "..");
116
+ /** The issue a report became: `{ repo: "owner/name", number, url }`, the url on github.com. Problems as a list. */
117
+ export function validIssue(issue) {
118
+ if (!issue || typeof issue !== "object" || Array.isArray(issue)) return ["issue is not an object"];
119
+ const errs = [];
120
+ if (!isRepoName(issue.repo)) errs.push("issue.repo is not owner/name");
121
+ if (!Number.isInteger(issue.number) || issue.number < 1) errs.push("issue.number is not a positive whole number");
122
+ if (typeof issue.url !== "string" || !issue.url.startsWith("https://github.com/")) errs.push("issue.url is not a https://github.com/ link");
123
+ return errs;
124
+ }
60
125
 
61
126
  const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
127
+ // The platform's secure random source where there is one (every browser and Node 19+), Math.random only where there is not.
62
128
  const randomSix = () => {
129
+ const c = globalThis.crypto;
130
+ const bytes = typeof c?.getRandomValues === "function" ? c.getRandomValues(new Uint8Array(6)) : Uint8Array.from({ length: 6 }, () => Math.floor(Math.random() * 256));
63
131
  let s = "";
64
- for (let i = 0; i < 6; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
132
+ for (const b of bytes) s += ALPHABET[b % ALPHABET.length];
65
133
  return s;
66
134
  };
67
135
  export function newReportId(now = new Date(), rand = randomSix) {
@@ -95,8 +163,13 @@ export function validateReport(raw, { ceilings = CEILINGS } = {}) {
95
163
  if (!ID.test(String(raw.id ?? ""))) errs.push("the id is not a report id");
96
164
  if (!KINDS.includes(raw.kind)) errs.push(`kind ${JSON.stringify(raw.kind)} is not one of ${KINDS.join(", ")}`);
97
165
  if (!ROLES.includes(raw.who?.role)) errs.push(`role ${JSON.stringify(raw.who?.role)} is not one of ${ROLES.join(", ")}`);
98
- for (const p of findKey(raw, "email")) errs.push(`${p}: a report never carries an email`);
166
+ // The one place an email may sit is the sender's own way to be reached, who.contact.email, and only there.
167
+ for (const p of findKey(raw, "email")) if (p !== "report.who.contact.email") errs.push(`${p}: a report never carries an email`);
168
+ if (raw.who?.contact !== undefined) errs.push(...validContact(raw.who.contact));
169
+ if (raw.diagnostics?.at !== undefined && !isIsoTime(raw.diagnostics.at)) errs.push("diagnostics.at is not an ISO time");
170
+ if (raw.context !== undefined) errs.push(...validContext(raw.context));
99
171
  if (String(raw.where?.route ?? "").includes("?")) errs.push("where.route keeps a query string");
172
+ if (raw.issue !== undefined) errs.push(...validIssue(raw.issue));
100
173
  for (const k of Object.keys(raw.diagnostics ?? {})) {
101
174
  if (!DIAGNOSTIC_KEYS.includes(k)) errs.push(`diagnostics.${k} is not one of ${DIAGNOSTIC_KEYS.join(", ")}`);
102
175
  }
@@ -0,0 +1,169 @@
1
+ // THE GITHUB CALLS. Web-standard fetch only, injectable so tests and any runtime can pass their own. The token is the
2
+ // filer's (`gh auth token` on a Mac) or an app server's own; it is sent to api.github.com and nowhere else.
3
+ import { isRepoName } from '../contract.mjs'
4
+ import { composeIssue, type ComposeOptions, type Draft } from './compose.ts'
5
+ /** An issue carries at most this many labels (the composer writes two). */
6
+ const MAX_LABELS = 10
7
+ import type { AttachmentHost, AttachmentFile } from './hosts.ts'
8
+ import { canFileDirectly, type RelayFiler } from './filer.ts'
9
+
10
+ export type Fetch = typeof globalThis.fetch
11
+ export interface GitHubConfig { repo: string; token: string; fetch?: Fetch; /** Default https://api.github.com */ api?: string }
12
+
13
+ export class GitHubError extends Error {
14
+ status: number
15
+ body: unknown
16
+ constructor(message: string, status: number, body: unknown) { super(message); this.name = 'GitHubError'; this.status = status; this.body = body }
17
+ }
18
+
19
+ const API = 'https://api.github.com'
20
+ function repoOf(repo: string): string {
21
+ if (!isRepoName(repo)) throw new Error(`${JSON.stringify(repo)} is not owner/name`)
22
+ return repo
23
+ }
24
+
25
+ /** One REST call. Answers the parsed body, or throws a GitHubError carrying GitHub's status. */
26
+ export async function github(cfg: { token: string; fetch?: Fetch; api?: string }, method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> {
27
+ const f = cfg.fetch ?? globalThis.fetch
28
+ const res = await f(`${cfg.api ?? API}${path}`, {
29
+ method,
30
+ headers: {
31
+ accept: 'application/vnd.github+json',
32
+ authorization: `Bearer ${cfg.token}`,
33
+ 'x-github-api-version': '2022-11-28',
34
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
35
+ },
36
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
37
+ })
38
+ const textBody = await res.text()
39
+ let parsed: unknown = null
40
+ try { parsed = textBody ? JSON.parse(textBody) : null } catch { parsed = textBody }
41
+ if (!res.ok) throw new GitHubError(`GitHub answered ${res.status} to ${method} ${path}`, res.status, parsed)
42
+ return { status: res.status, body: parsed }
43
+ }
44
+
45
+ const COLORS: Record<string, string> = { bug: 'd73a4a', enhancement: 'a2eeef', feedback: 'c5def5' }
46
+
47
+ /** Opens one issue. Missing labels are created first; one that exists (422) or that this account may not create
48
+ * (403/404: a read-only filer) is skipped, and GitHub then keeps only the labels the filer may set. */
49
+ export async function fileIssue(cfg: GitHubConfig, issue: { title: string; body: string; labels: string[] }): Promise<{ number: number; url: string }> {
50
+ const repo = repoOf(cfg.repo)
51
+ const labels = issue.labels.filter((l) => typeof l === 'string' && l.length && l.length <= 50).slice(0, MAX_LABELS)
52
+ for (const name of labels) {
53
+ try { await github(cfg, 'POST', `/repos/${repo}/labels`, { name, color: COLORS[name] ?? 'ededed' }) } catch (e) {
54
+ if (!(e instanceof GitHubError) || ![403, 404, 422].includes(e.status)) throw e
55
+ }
56
+ }
57
+ const got = await github(cfg, 'POST', `/repos/${repo}/issues`, { title: issue.title, body: issue.body, labels })
58
+ return { number: got.body.number, url: got.body.html_url }
59
+ }
60
+
61
+ /** Replaces an issue's body (the filer's own issue: read access is enough to edit what you opened). */
62
+ export async function updateIssue(cfg: GitHubConfig, number: number, change: { body: string }): Promise<void> {
63
+ await github(cfg, 'PATCH', `/repos/${repoOf(cfg.repo)}/issues/${number}`, { body: change.body })
64
+ }
65
+
66
+ /** A follow-up the person added later, as a comment on the same issue. */
67
+ export async function addFollowUp(cfg: GitHubConfig, number: number, body: string): Promise<{ id: number; url: string }> {
68
+ const got = await github(cfg, 'POST', `/repos/${repoOf(cfg.repo)}/issues/${number}/comments`, { body })
69
+ return { id: got.body.id, url: got.body.html_url }
70
+ }
71
+
72
+ export interface IssueState { repo: string; number: number; state: string | null; stateReason: string | null; /** First line of the last comment, on a closed issue: the reason. */ reason?: string; missing?: true }
73
+
74
+ /** The state of many issues in one GraphQL call (in chunks of 50). Repo names travel as variables, never in the query. */
75
+ export async function issueStates(cfg: { token: string; fetch?: Fetch; api?: string }, issues: { repo: string; number: number }[]): Promise<IssueState[]> {
76
+ const out: IssueState[] = []
77
+ for (let start = 0; start < issues.length; start += 50) {
78
+ const chunk = issues.slice(start, start + 50)
79
+ const vars: Record<string, string | number> = {}
80
+ const decl: string[] = []
81
+ const parts: string[] = []
82
+ chunk.forEach((it, i) => {
83
+ const [owner, name] = repoOf(it.repo).split('/')
84
+ if (!Number.isInteger(it.number) || it.number < 1) throw new Error(`${it.number} is not an issue number`)
85
+ vars[`o${i}`] = owner; vars[`n${i}`] = name; vars[`k${i}`] = it.number
86
+ decl.push(`$o${i}: String!, $n${i}: String!, $k${i}: Int!`)
87
+ parts.push(`i${i}: repository(owner: $o${i}, name: $n${i}) { issue(number: $k${i}) { state stateReason comments(last: 1) { nodes { body } } } }`)
88
+ })
89
+ const query = `query(${decl.join(', ')}) { ${parts.join(' ')} }`
90
+ const got = await github(cfg, 'POST', '/graphql', { query, variables: vars })
91
+ const data = got.body?.data
92
+ if (!data) throw new GitHubError('GitHub answered no data to the issue state query', got.status, got.body)
93
+ chunk.forEach((it, i) => {
94
+ const issue = data[`i${i}`]?.issue
95
+ if (!issue) { out.push({ repo: it.repo, number: it.number, state: null, stateReason: null, missing: true }); return }
96
+ const row: IssueState = { repo: it.repo, number: it.number, state: issue.state, stateReason: issue.stateReason ?? null }
97
+ const last = issue.comments?.nodes?.at(-1)?.body
98
+ const line = typeof last === 'string' ? last.trim().split('\n')[0].trim() : ''
99
+ if (String(issue.state).toUpperCase() === 'CLOSED' && line) row.reason = line
100
+ out.push(row)
101
+ })
102
+ }
103
+ return out
104
+ }
105
+
106
+ /** GitHub's state onto the person's three: open, done (closed as completed), not planned (as not planned, or a duplicate). */
107
+ export function issueStatus({ state, stateReason }: { state: string | null; stateReason?: string | null }): 'open' | 'done' | 'not-planned' {
108
+ if (String(state).toUpperCase() !== 'CLOSED') return 'open'
109
+ return ['NOT_PLANNED', 'DUPLICATE'].includes(String(stateReason).toUpperCase()) ? 'not-planned' : 'done'
110
+ }
111
+
112
+ export interface FileSuggestionInput {
113
+ /** The report (redacted already, when the privacy catch asked for it). */
114
+ report: Draft
115
+ compose?: Omit<ComposeOptions, 'attachmentUrls' | 'pending'>
116
+ /** The bytes of the report's media and voice files, by the names the report gives them. */
117
+ files: AttachmentFile[]
118
+ attachments?: AttachmentHost
119
+ }
120
+ export interface FiledSuggestion { number: number; url: string; attached: Record<string, string>; pending: string[]; /** How it was filed: with the filer's own token, or through a suggestion relay. */ via: 'direct' | 'relay' }
121
+ /** Where to file: the repo, the filer's own token when they have one, and a relay for when that token cannot file there. */
122
+ export type FilingConfig = Omit<GitHubConfig, 'token'> & { token?: string; relay?: RelayFiler; /** The app's id in the relay's own table, when filing through one. */ app?: string }
123
+
124
+ const reportFiles = (r: Draft) => [...(r.media ?? []), ...(r.voice ?? [])].map((f) => f.file)
125
+
126
+ /** Saves the files the issue is still waiting for, then edits their links into the body. Call again later with the
127
+ * same `attached` to finish what failed: the issue keeps saying a file is coming until it is there. */
128
+ export async function attachPending(cfg: GitHubConfig, input: FileSuggestionInput & { number: number; attached?: Record<string, string> }): Promise<{ attached: Record<string, string>; pending: string[] }> {
129
+ const attached = { ...(input.attached ?? {}) }
130
+ const wanted = new Set(reportFiles(input.report))
131
+ let added = 0
132
+ if (input.attachments) {
133
+ for (const f of input.files) {
134
+ if (!wanted.has(f.name) || attached[f.name]) continue
135
+ try {
136
+ attached[f.name] = (await input.attachments.put({ repo: cfg.repo, issue: input.number, reportId: String(input.report.id), }, f)).url
137
+ added++
138
+ } catch { /* left pending: the issue still says it is coming, and a later call retries it */ }
139
+ }
140
+ }
141
+ if (added) await updateIssue(cfg, input.number, { body: composeIssue(input.report, { ...input.compose, attachmentUrls: attached }).body })
142
+ return { attached, pending: [...wanted].filter((n) => !attached[n]) }
143
+ }
144
+
145
+ /** The whole filing. With a token that can see the repo it files directly, in the order that never shows an incomplete
146
+ * issue as complete: open the issue saying what is being added (opening it is the proof the filer can file there),
147
+ * save each file against that issue, then edit the links in. With no token, or one that cannot see the repo, and a
148
+ * `relay`, the relay files it (the files, then the issue, as its App) for someone with no access at all. */
149
+ export async function fileSuggestion(cfg: FilingConfig, input: FileSuggestionInput): Promise<FiledSuggestion> {
150
+ const direct = cfg.token ? (!cfg.relay || await canFileDirectly({ repo: cfg.repo, token: cfg.token, fetch: cfg.fetch, api: cfg.api })) : false
151
+ if (!direct) {
152
+ if (!cfg.relay) throw new Error('fileSuggestion needs a token that can file on the repo, or a relay')
153
+ const made = await cfg.relay.file({
154
+ repo: cfg.repo, ...(cfg.app ? { app: cfg.app } : {}), report: input.report as Record<string, unknown>, files: input.files,
155
+ ...(input.compose?.filerName ? { filerName: input.compose.filerName } : {}),
156
+ ...(input.compose?.appName ? { appName: input.compose.appName } : {}),
157
+ ...(input.compose?.appVersion ? { appVersion: input.compose.appVersion } : {}),
158
+ ...(input.compose?.transcript ? { transcript: input.compose.transcript } : {}),
159
+ })
160
+ return { ...made, attached: {}, pending: [], via: 'relay' }
161
+ }
162
+ const own = { ...cfg, token: cfg.token as string }
163
+ const hasFiles = reportFiles(input.report).length > 0
164
+ const first = composeIssue(input.report, { ...input.compose, pending: hasFiles })
165
+ const issue = await fileIssue(own, first)
166
+ if (!hasFiles) return { ...issue, attached: {}, pending: [], via: 'direct' }
167
+ const rest = await attachPending(own, { ...input, number: issue.number })
168
+ return { ...issue, ...rest, via: 'direct' }
169
+ }