@remit/web-client 0.0.24 → 0.0.26
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/package.json +1 -1
- package/src/components/mail/MailboxPane.tsx +12 -0
- package/src/components/mail/MessageList.tsx +339 -33
- package/src/hooks/useEscalatedDelete.ts +244 -0
- package/src/lib/bug-report.test.ts +92 -0
- package/src/lib/bug-report.ts +88 -28
- package/src/lib/bulk-delete.test.ts +456 -0
- package/src/lib/bulk-delete.ts +251 -0
- package/src/lib/escalation-label.test.ts +49 -0
- package/src/lib/escalation-label.ts +32 -0
- package/src/lib/format.test.ts +7 -0
- package/src/lib/format.ts +5 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
3
|
+
threadOperationsListThreadsQueryKey,
|
|
4
|
+
threadOperationsSearchThreadsQueryKey,
|
|
5
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
+
import {
|
|
7
|
+
messageBulkOperationsDeleteMessages,
|
|
8
|
+
threadOperationsSearchThreads,
|
|
9
|
+
} from "@remit/api-http-client/sdk.gen.ts";
|
|
10
|
+
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
13
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
14
|
+
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
15
|
+
import {
|
|
16
|
+
type BulkDeleteProgress,
|
|
17
|
+
countMatches,
|
|
18
|
+
type DeleteRunOutcome,
|
|
19
|
+
type FetchIdsPage,
|
|
20
|
+
runChunkedDelete,
|
|
21
|
+
runPredicateDelete,
|
|
22
|
+
} from "@/lib/bulk-delete";
|
|
23
|
+
|
|
24
|
+
/** The predicate a search-scoped delete re-issues on every page — the same
|
|
25
|
+
* filters the visible list is searching with, minus pagination/count knobs. */
|
|
26
|
+
export type EscalationSearchQuery = Pick<
|
|
27
|
+
NonNullable<ThreadOperationsSearchThreadsData["query"]>,
|
|
28
|
+
"order" | "query" | "subject" | "from" | "unread" | "starred" | "attachments"
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
/** Page size for both the counting and the execution loop. Set to the write
|
|
32
|
+
* side's own 100-id cap so an execution page IS a delete chunk — no
|
|
33
|
+
* in-memory accumulation step between reading ids and sending them. Counting
|
|
34
|
+
* doesn't have that constraint but reuses the same page size rather than
|
|
35
|
+
* adding a second one to reason about. */
|
|
36
|
+
const PAGE_SIZE = 100;
|
|
37
|
+
|
|
38
|
+
export type EscalationPhase =
|
|
39
|
+
| { kind: "idle" }
|
|
40
|
+
| { kind: "counting"; countSoFar: number }
|
|
41
|
+
| { kind: "escalated"; total: number };
|
|
42
|
+
|
|
43
|
+
interface UseEscalatedDeleteOptions {
|
|
44
|
+
mailboxId: string;
|
|
45
|
+
/** Owning account, forwarded to the unseen-count invalidation on completion. */
|
|
46
|
+
accountId?: string;
|
|
47
|
+
/** Disables escalation entirely (e.g. not searching, or desktop — this is a
|
|
48
|
+
* mobile-only affordance). Resets any in-flight phase back to idle. */
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
/** Identifies the active predicate; escalation resets to idle whenever this
|
|
51
|
+
* changes (a different search is a different question). */
|
|
52
|
+
predicateKey: string;
|
|
53
|
+
searchQuery: EscalationSearchQuery;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UseEscalatedDeleteResult {
|
|
57
|
+
phase: EscalationPhase;
|
|
58
|
+
/** Begin paging the predicate's full match set to find its total. */
|
|
59
|
+
escalate: () => void;
|
|
60
|
+
/** Stop whatever's running — counting or a delete — at the next page
|
|
61
|
+
* boundary. A no-op when nothing is running. */
|
|
62
|
+
stop: () => void;
|
|
63
|
+
/** Drop an escalated selection back to bounded without confirming anything. */
|
|
64
|
+
clear: () => void;
|
|
65
|
+
/** True while a chunked delete (bounded->100 ids, or the escalated
|
|
66
|
+
* predicate) is running. */
|
|
67
|
+
isDeleting: boolean;
|
|
68
|
+
deleteProgress: BulkDeleteProgress | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Runs a chunked delete. Pass `ids` for a materialized (bounded) selection;
|
|
71
|
+
* omit it to delete the escalated predicate (`phase` must be "escalated").
|
|
72
|
+
* Resolves once the run ends for any reason — cancelled, errored, or
|
|
73
|
+
* complete — with a `done`/`failedIds` outcome the caller feeds to
|
|
74
|
+
* `resolveSelectionAfterDelete` to decide what selection looks like next.
|
|
75
|
+
* Infrastructure failures are reported through the app's existing
|
|
76
|
+
* escalation seam (`pushError`, which itself escalates a 5xx/exception to
|
|
77
|
+
* the fatal overlay) — not swallowed here.
|
|
78
|
+
*/
|
|
79
|
+
runDelete: (ids?: string[]) => Promise<DeleteRunOutcome>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const useEscalatedDelete = ({
|
|
83
|
+
mailboxId,
|
|
84
|
+
accountId,
|
|
85
|
+
enabled,
|
|
86
|
+
predicateKey,
|
|
87
|
+
searchQuery,
|
|
88
|
+
}: UseEscalatedDeleteOptions): UseEscalatedDeleteResult => {
|
|
89
|
+
const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
|
|
90
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
91
|
+
const [deleteProgress, setDeleteProgress] = useState<
|
|
92
|
+
BulkDeleteProgress | undefined
|
|
93
|
+
>(undefined);
|
|
94
|
+
const cancelRef = useRef(false);
|
|
95
|
+
const queryClient = useQueryClient();
|
|
96
|
+
const { pushError } = useErrorBanners();
|
|
97
|
+
|
|
98
|
+
// A different search (or leaving search/desktop) makes any in-flight
|
|
99
|
+
// escalation meaningless — it would otherwise keep counting or offering to
|
|
100
|
+
// delete a predicate the visible list no longer reflects.
|
|
101
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself is unconditional, not a value read from either.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
cancelRef.current = true;
|
|
104
|
+
setPhase({ kind: "idle" });
|
|
105
|
+
}, [enabled, predicateKey]);
|
|
106
|
+
|
|
107
|
+
const searchQueryRef = useRef(searchQuery);
|
|
108
|
+
searchQueryRef.current = searchQuery;
|
|
109
|
+
|
|
110
|
+
const fetchIdsPage = useCallback<FetchIdsPage>(
|
|
111
|
+
async (continuationToken) => {
|
|
112
|
+
const { data } = await threadOperationsSearchThreads({
|
|
113
|
+
path: { mailboxId },
|
|
114
|
+
query: {
|
|
115
|
+
...searchQueryRef.current,
|
|
116
|
+
continuationToken,
|
|
117
|
+
limit: PAGE_SIZE,
|
|
118
|
+
},
|
|
119
|
+
throwOnError: true,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
ids: (data.items ?? []).map((item) => item.messageId),
|
|
123
|
+
continuationToken: data.continuationToken,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
[mailboxId],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const deleteBatch = useCallback(async (ids: string[]) => {
|
|
130
|
+
const { data } = await messageBulkOperationsDeleteMessages({
|
|
131
|
+
body: { messageIds: ids },
|
|
132
|
+
throwOnError: true,
|
|
133
|
+
});
|
|
134
|
+
return data;
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const invalidateAfterDelete = useCallback(() => {
|
|
138
|
+
queryClient.invalidateQueries({
|
|
139
|
+
queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
|
|
140
|
+
});
|
|
141
|
+
queryClient.invalidateQueries({
|
|
142
|
+
queryKey: threadOperationsSearchThreadsQueryKey({ path: { mailboxId } }),
|
|
143
|
+
});
|
|
144
|
+
if (accountId) {
|
|
145
|
+
queryClient.invalidateQueries({
|
|
146
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
147
|
+
path: { accountId },
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}, [queryClient, mailboxId, accountId]);
|
|
152
|
+
|
|
153
|
+
const escalate = useCallback(() => {
|
|
154
|
+
cancelRef.current = false;
|
|
155
|
+
setPhase({ kind: "counting", countSoFar: 0 });
|
|
156
|
+
countMatches(
|
|
157
|
+
fetchIdsPage,
|
|
158
|
+
(countSoFar) => setPhase({ kind: "counting", countSoFar }),
|
|
159
|
+
() => cancelRef.current,
|
|
160
|
+
).then((result) => {
|
|
161
|
+
if (result.error) {
|
|
162
|
+
pushError(
|
|
163
|
+
buildMutationErrorBanner(
|
|
164
|
+
"Couldn't count matching messages",
|
|
165
|
+
"The count didn't finish.",
|
|
166
|
+
result.error,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
setPhase({ kind: "idle" });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (result.cancelled) {
|
|
173
|
+
setPhase({ kind: "idle" });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
setPhase({ kind: "escalated", total: result.total });
|
|
177
|
+
});
|
|
178
|
+
}, [fetchIdsPage, pushError]);
|
|
179
|
+
|
|
180
|
+
const stop = useCallback(() => {
|
|
181
|
+
cancelRef.current = true;
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
const clear = useCallback(() => {
|
|
185
|
+
cancelRef.current = true;
|
|
186
|
+
setPhase({ kind: "idle" });
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const runDelete = useCallback(
|
|
190
|
+
async (ids?: string[]): Promise<DeleteRunOutcome> => {
|
|
191
|
+
cancelRef.current = false;
|
|
192
|
+
setIsDeleting(true);
|
|
193
|
+
const onProgress = (progress: BulkDeleteProgress) =>
|
|
194
|
+
setDeleteProgress(progress);
|
|
195
|
+
|
|
196
|
+
const outcome =
|
|
197
|
+
ids !== undefined
|
|
198
|
+
? await runChunkedDelete(
|
|
199
|
+
ids,
|
|
200
|
+
deleteBatch,
|
|
201
|
+
onProgress,
|
|
202
|
+
() => cancelRef.current,
|
|
203
|
+
)
|
|
204
|
+
: await runPredicateDelete(
|
|
205
|
+
fetchIdsPage,
|
|
206
|
+
phase.kind === "escalated" ? phase.total : 0,
|
|
207
|
+
deleteBatch,
|
|
208
|
+
onProgress,
|
|
209
|
+
() => cancelRef.current,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
setIsDeleting(false);
|
|
213
|
+
setDeleteProgress(undefined);
|
|
214
|
+
setPhase({ kind: "idle" });
|
|
215
|
+
|
|
216
|
+
if (outcome.error) {
|
|
217
|
+
pushError(
|
|
218
|
+
buildMutationErrorBanner(
|
|
219
|
+
outcome.done > 0
|
|
220
|
+
? `Stopped after ${outcome.done} — some messages weren't deleted`
|
|
221
|
+
: "Couldn't delete these messages",
|
|
222
|
+
"The delete didn't finish.",
|
|
223
|
+
outcome.error,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (outcome.done > 0) {
|
|
228
|
+
invalidateAfterDelete();
|
|
229
|
+
}
|
|
230
|
+
return outcome;
|
|
231
|
+
},
|
|
232
|
+
[deleteBatch, fetchIdsPage, phase, pushError, invalidateAfterDelete],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
phase,
|
|
237
|
+
escalate,
|
|
238
|
+
stop,
|
|
239
|
+
clear,
|
|
240
|
+
isDeleting,
|
|
241
|
+
deleteProgress,
|
|
242
|
+
runDelete,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
QUARANTINE_REPORT_DISCLAIMER,
|
|
5
|
+
type QuarantineEntry,
|
|
6
|
+
quarantineDemoEntries,
|
|
7
|
+
quarantineIssueTitle,
|
|
8
|
+
quarantineReportSections,
|
|
9
|
+
} from "@remit/ui";
|
|
3
10
|
import type { BugReportContext } from "./bug-report";
|
|
4
11
|
import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
|
|
5
12
|
|
|
@@ -211,3 +218,88 @@ describe("buildGitHubIssueUrl — truncation to fit the URL budget", () => {
|
|
|
211
218
|
);
|
|
212
219
|
});
|
|
213
220
|
});
|
|
221
|
+
|
|
222
|
+
describe("quarantine reports", () => {
|
|
223
|
+
// A flat multipart/mixed of 200 parts — the shape that first broke the
|
|
224
|
+
// fence. A message with a pathological part count is precisely the message
|
|
225
|
+
// that fails to parse, so a long report is the common case, not the tail.
|
|
226
|
+
const entry: QuarantineEntry = {
|
|
227
|
+
...quarantineDemoEntries[0],
|
|
228
|
+
structure: {
|
|
229
|
+
contentType: "multipart/mixed",
|
|
230
|
+
parts: Array.from({ length: 200 }, () => ({
|
|
231
|
+
contentType: "application/octet-stream",
|
|
232
|
+
})),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const ctx = (over?: Partial<BugReportContext>): BugReportContext => ({
|
|
237
|
+
...baseCtx,
|
|
238
|
+
title: quarantineIssueTitle(entry),
|
|
239
|
+
quarantine: quarantineReportSections(entry),
|
|
240
|
+
...over,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const fenceCount = (body: string): number =>
|
|
244
|
+
(body.match(/^```/gm) ?? []).length;
|
|
245
|
+
|
|
246
|
+
it("carries the diagnostics section and the supplied title", () => {
|
|
247
|
+
const decoded = decode(
|
|
248
|
+
buildGitHubIssueUrl(
|
|
249
|
+
ctx({ quarantine: quarantineReportSections(quarantineDemoEntries[0]) }),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
assert.ok(decoded.includes("UnterminatedMultipartBoundary"));
|
|
253
|
+
assert.ok(decoded.includes("Message quarantined:"));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("truncates a long report without cutting inside the code fence", () => {
|
|
257
|
+
const url = buildGitHubIssueUrl(ctx());
|
|
258
|
+
const body = decode(url);
|
|
259
|
+
assert.ok(
|
|
260
|
+
url.length <= 8000,
|
|
261
|
+
`Expected a URL under 8000 chars, got ${url.length}`,
|
|
262
|
+
);
|
|
263
|
+
assert.ok(body.includes("truncated"), "Expected a truncation marker");
|
|
264
|
+
assert.equal(
|
|
265
|
+
fenceCount(body) % 2,
|
|
266
|
+
0,
|
|
267
|
+
`Unbalanced code fence in truncated body:\n${body.slice(-400)}`,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keeps the redaction disclaimer on a truncated report", () => {
|
|
272
|
+
const body = decode(buildGitHubIssueUrl(ctx()));
|
|
273
|
+
assert.ok(
|
|
274
|
+
body.includes(QUARANTINE_REPORT_DISCLAIMER),
|
|
275
|
+
"A truncated report must still state what was withheld",
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("keeps the reproduction template outside the code block", () => {
|
|
280
|
+
const body = decode(buildGitHubIssueUrl(ctx()));
|
|
281
|
+
const afterLastFence = body.slice(body.lastIndexOf("```") + 3);
|
|
282
|
+
assert.ok(afterLastFence.includes("## Steps to reproduce"));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("truncates the longer section when a stack is present too", () => {
|
|
286
|
+
const url = buildGitHubIssueUrl(
|
|
287
|
+
ctx({ stack: "at foo\n".repeat(2000), errorMessage: "boom" }),
|
|
288
|
+
);
|
|
289
|
+
assert.ok(
|
|
290
|
+
url.length <= 8000,
|
|
291
|
+
`Expected a URL under 8000 chars, got ${url.length}`,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("Copy full details keeps every part the URL had to drop", () => {
|
|
296
|
+
const parts = (text: string): number =>
|
|
297
|
+
(text.match(/- application\/octet-stream/g) ?? []).length;
|
|
298
|
+
const details = buildBugReportDetails(ctx());
|
|
299
|
+
assert.equal(parts(details), 200);
|
|
300
|
+
assert.ok(
|
|
301
|
+
parts(decode(buildGitHubIssueUrl(ctx()))) < 200,
|
|
302
|
+
"Expected the URL body to have dropped parts",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
});
|
package/src/lib/bug-report.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { QuarantineReportSections } from "@remit/ui";
|
|
1
2
|
import {
|
|
2
3
|
APP_BUILD_TIME,
|
|
3
4
|
APP_SHA,
|
|
@@ -15,7 +16,7 @@ import { getRecentErrors } from "./console-errors";
|
|
|
15
16
|
const MAX_ISSUE_URL_LENGTH = 7500;
|
|
16
17
|
|
|
17
18
|
const TRUNCATION_MARKER =
|
|
18
|
-
"\n… (truncated —
|
|
19
|
+
"\n… (truncated — the copy action on this screen has the full report)";
|
|
19
20
|
|
|
20
21
|
export interface BugReportContext {
|
|
21
22
|
appSha: string;
|
|
@@ -33,6 +34,14 @@ export interface BugReportContext {
|
|
|
33
34
|
stack?: string;
|
|
34
35
|
/** React component stack from the error boundary, when available. */
|
|
35
36
|
componentStack?: string;
|
|
37
|
+
/**
|
|
38
|
+
* A quarantined message's diagnostics, when the report is filed from the
|
|
39
|
+
* quarantine surface. Split into sections by `quarantineReportSections` in
|
|
40
|
+
* @remit/ui so the MIME tree is fenced after truncation, never before.
|
|
41
|
+
*/
|
|
42
|
+
quarantine?: QuarantineReportSections;
|
|
43
|
+
/** Overrides the derived title, e.g. for a quarantine report. */
|
|
44
|
+
title?: string;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/** Fields callers can seed from a caught error. */
|
|
@@ -40,6 +49,9 @@ export interface BugReportSeed {
|
|
|
40
49
|
errorMessage?: string;
|
|
41
50
|
stack?: string;
|
|
42
51
|
componentStack?: string;
|
|
52
|
+
quarantine?: QuarantineReportSections;
|
|
53
|
+
/** Overrides the derived title, e.g. for a quarantine report. */
|
|
54
|
+
title?: string;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
@@ -56,6 +68,8 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
|
56
68
|
errorMessage: seed?.errorMessage,
|
|
57
69
|
stack: seed?.stack,
|
|
58
70
|
componentStack: seed?.componentStack,
|
|
71
|
+
quarantine: seed?.quarantine,
|
|
72
|
+
title: seed?.title,
|
|
59
73
|
};
|
|
60
74
|
}
|
|
61
75
|
|
|
@@ -63,7 +77,16 @@ function fencedBlock(content: string): string {
|
|
|
63
77
|
return ["```", content, "```"].join("\n");
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
|
|
80
|
+
interface BodyOverrides {
|
|
81
|
+
stack?: string;
|
|
82
|
+
/** Replaces the MIME-tree section only; it is fenced after this is applied. */
|
|
83
|
+
quarantineStructure?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildIssueBody(
|
|
87
|
+
ctx: BugReportContext,
|
|
88
|
+
overrides?: BodyOverrides,
|
|
89
|
+
): string {
|
|
67
90
|
const errorSection =
|
|
68
91
|
ctx.recentErrors.length > 0
|
|
69
92
|
? ctx.recentErrors.map((e) => ` - ${e}`).join("\n")
|
|
@@ -85,7 +108,20 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
|
|
|
85
108
|
lines.push("## Error", ctx.errorMessage, "");
|
|
86
109
|
}
|
|
87
110
|
|
|
88
|
-
|
|
111
|
+
if (ctx.quarantine) {
|
|
112
|
+
const structure =
|
|
113
|
+
overrides?.quarantineStructure ?? ctx.quarantine.structure;
|
|
114
|
+
lines.push(
|
|
115
|
+
ctx.quarantine.head,
|
|
116
|
+
"",
|
|
117
|
+
fencedBlock(structure),
|
|
118
|
+
"",
|
|
119
|
+
ctx.quarantine.disclaimer,
|
|
120
|
+
"",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolvedStack = overrides?.stack ?? ctx.stack;
|
|
89
125
|
if (resolvedStack) {
|
|
90
126
|
lines.push("## Stacktrace", fencedBlock(resolvedStack), "");
|
|
91
127
|
}
|
|
@@ -111,6 +147,7 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
|
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
function buildIssueTitle(ctx: BugReportContext): string {
|
|
150
|
+
if (ctx.title) return ctx.title;
|
|
114
151
|
if (!ctx.errorMessage) return "Bug: ";
|
|
115
152
|
const firstLine = ctx.errorMessage.split("\n")[0].trim();
|
|
116
153
|
const clipped =
|
|
@@ -118,10 +155,10 @@ function buildIssueTitle(ctx: BugReportContext): string {
|
|
|
118
155
|
return `Bug: ${clipped}`;
|
|
119
156
|
}
|
|
120
157
|
|
|
121
|
-
function issueUrl(ctx: BugReportContext,
|
|
158
|
+
function issueUrl(ctx: BugReportContext, overrides?: BodyOverrides): string {
|
|
122
159
|
const params = new URLSearchParams({
|
|
123
160
|
title: buildIssueTitle(ctx),
|
|
124
|
-
body: buildIssueBody(ctx,
|
|
161
|
+
body: buildIssueBody(ctx, overrides),
|
|
125
162
|
});
|
|
126
163
|
return `${GITHUB_NEW_ISSUE_URL}?${params.toString()}`;
|
|
127
164
|
}
|
|
@@ -137,40 +174,63 @@ export function buildBugReportDetails(ctx: BugReportContext): string {
|
|
|
137
174
|
|
|
138
175
|
/**
|
|
139
176
|
* Build a prefilled GitHub new-issue URL. When the full body would exceed the
|
|
140
|
-
* URL budget, the
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
177
|
+
* URL budget, the longest truncatable section — a stacktrace, or a quarantine
|
|
178
|
+
* report's MIME tree — is binary-searched to the longest prefix that fits and
|
|
179
|
+
* marked; everything else is preserved. The component stack is dropped from
|
|
180
|
+
* the URL when truncation kicks in — it survives in the copy-details report.
|
|
181
|
+
*
|
|
182
|
+
* The quarantine MIME tree is handed over unfenced and fenced by
|
|
183
|
+
* `buildIssueBody` afterwards, so a cut can never land inside a code fence.
|
|
184
|
+
* The disclaimer is appended by the builder rather than carried in the
|
|
185
|
+
* truncated text, so a truncated report still states what was withheld.
|
|
144
186
|
*/
|
|
145
187
|
export function buildGitHubIssueUrl(ctx: BugReportContext): string {
|
|
146
188
|
const full = issueUrl(ctx);
|
|
147
189
|
if (full.length <= MAX_ISSUE_URL_LENGTH) return full;
|
|
148
190
|
|
|
149
|
-
const stack = ctx.stack ?? "";
|
|
150
191
|
const withoutComponentStack: BugReportContext = {
|
|
151
192
|
...ctx,
|
|
152
193
|
componentStack: undefined,
|
|
153
194
|
};
|
|
154
195
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
// Both sections can be present — a seed is not discriminated — and cutting
|
|
197
|
+
// one to nothing does not necessarily fit the other. Truncate the longest
|
|
198
|
+
// first and keep going while the budget is still exceeded.
|
|
199
|
+
const sections: Array<[keyof BodyOverrides, string]> = [];
|
|
200
|
+
if (ctx.stack) sections.push(["stack", ctx.stack]);
|
|
201
|
+
if (ctx.quarantine?.structure) {
|
|
202
|
+
sections.push(["quarantineStructure", ctx.quarantine.structure]);
|
|
203
|
+
}
|
|
204
|
+
sections.sort((a, b) => b[1].length - a[1].length);
|
|
205
|
+
|
|
206
|
+
let overrides: BodyOverrides = {};
|
|
207
|
+
for (const [field, text] of sections) {
|
|
208
|
+
if (
|
|
209
|
+
issueUrl(withoutComponentStack, overrides).length <= MAX_ISSUE_URL_LENGTH
|
|
210
|
+
) {
|
|
211
|
+
break;
|
|
169
212
|
}
|
|
213
|
+
let lo = 0;
|
|
214
|
+
let hi = text.length;
|
|
215
|
+
let best = 0;
|
|
216
|
+
while (lo <= hi) {
|
|
217
|
+
const mid = (lo + hi) >> 1;
|
|
218
|
+
const candidate = issueUrl(withoutComponentStack, {
|
|
219
|
+
...overrides,
|
|
220
|
+
[field]: text.slice(0, mid) + TRUNCATION_MARKER,
|
|
221
|
+
});
|
|
222
|
+
if (candidate.length <= MAX_ISSUE_URL_LENGTH) {
|
|
223
|
+
best = mid;
|
|
224
|
+
lo = mid + 1;
|
|
225
|
+
} else {
|
|
226
|
+
hi = mid - 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
overrides = {
|
|
230
|
+
...overrides,
|
|
231
|
+
[field]: text.slice(0, best) + TRUNCATION_MARKER,
|
|
232
|
+
};
|
|
170
233
|
}
|
|
171
234
|
|
|
172
|
-
return issueUrl(
|
|
173
|
-
withoutComponentStack,
|
|
174
|
-
stack.slice(0, best) + TRUNCATION_MARKER,
|
|
175
|
-
);
|
|
235
|
+
return issueUrl(withoutComponentStack, overrides);
|
|
176
236
|
}
|