@supersuit/suggest-a-change 0.1.13 → 0.2.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/CHANGELOG.md +23 -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 +159 -0
- package/dist/github/compose.js +312 -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 +9 -2
- 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 +362 -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/src/contract.mjs
CHANGED
|
@@ -14,54 +14,122 @@ export const CEILINGS = {
|
|
|
14
14
|
perPersonPerDay: 20,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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", "
|
|
27
|
-
"received": { actor: "app", next: ["
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
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
|
-
|
|
56
|
-
|
|
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]
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|