@zhangj1164/dsh-github-issue 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,627 @@
1
+ import s from "@deepseek-ai/schemastery";
2
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
+ import { EMPTY_RESPONSE_CODE, createUserMessage } from "@deepseek-ai/dsh-llm";
4
+ //#region src/llm-text.ts
5
+ /**
6
+ * Model-text helper for this service: ask a model for one block of text and
7
+ * keep the reason when there is none.
8
+ *
9
+ * The DSH LLM runtime normalizes every adapter, credential, routing, and
10
+ * transport failure into a terminal `finish` chunk carrying an
11
+ * {@link LlmFailure}. Collapsing that into a bare `undefined` destroys the
12
+ * only actionable facts — the machine-routing `code` (`NO_ADAPTER`,
13
+ * `MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`, …), the provider-neutral
14
+ * message, and the HTTP status — and makes a misconfigured provider route
15
+ * indistinguishable from a model that legitimately returned nothing. This
16
+ * service did exactly that, and reported every one of those conditions as
17
+ * "the model produced no output".
18
+ *
19
+ * This is the same helper `dsh-memo` uses, kept structurally identical
20
+ * (`packages/memo/dsh-memo/src/llm-text.ts`) so the two services report a
21
+ * failure the same way. It is duplicated rather than shared because
22
+ * `dsh-github-issue` is the lower layer — `dsh-memo` depends on it, not the
23
+ * other way round — and a third consumer is the point at which the pair is
24
+ * worth extracting.
25
+ *
26
+ * @module @zhangj1164/dsh-github-issue/llm-text
27
+ */
28
+ /**
29
+ * Stream one model call and collect its text output.
30
+ *
31
+ * @param llm - the DSH `llm` service, or `undefined` when it is not mounted.
32
+ * @param route - the provider and model to call.
33
+ * @param system - system prompt text.
34
+ * @param userText - user message text.
35
+ * @returns the collected text, or the preserved terminal-failure facts.
36
+ */
37
+ async function streamLlmText(llm, route, system, userText) {
38
+ if (llm === void 0) return {
39
+ ok: false,
40
+ failure: {
41
+ code: "LLM_UNAVAILABLE",
42
+ message: "the llm service is not mounted"
43
+ }
44
+ };
45
+ if (route.provider.length === 0 || route.model.length === 0) return {
46
+ ok: false,
47
+ failure: {
48
+ code: "NO_MODEL_ROUTE",
49
+ message: "no model route is configured; set the provider and model on this service or on agent-default-model"
50
+ }
51
+ };
52
+ const message = createUserMessage({
53
+ content: [{
54
+ type: "text",
55
+ text: userText
56
+ }],
57
+ source: { kind: "user" }
58
+ });
59
+ const options = {
60
+ provider: route.provider,
61
+ model: route.model,
62
+ messages: [message],
63
+ system
64
+ };
65
+ let text = "";
66
+ try {
67
+ for await (const chunk of llm.stream(options)) if (chunk.type === "text-delta") text += chunk.text;
68
+ else if (chunk.type === "finish" && (chunk.reason.kind === "error" || chunk.reason.kind === "aborted")) {
69
+ const failure = chunk.reason.failure;
70
+ return {
71
+ ok: false,
72
+ failure: {
73
+ code: failure.code,
74
+ message: failure.message,
75
+ ...failure.status === void 0 ? {} : { status: failure.status }
76
+ }
77
+ };
78
+ }
79
+ } catch (error) {
80
+ return {
81
+ ok: false,
82
+ failure: {
83
+ code: "LLM_STREAM_THREW",
84
+ message: error instanceof Error ? error.message : String(error)
85
+ }
86
+ };
87
+ }
88
+ if (text.length === 0) return {
89
+ ok: false,
90
+ failure: {
91
+ code: EMPTY_RESPONSE_CODE,
92
+ message: "the model returned no text for this request"
93
+ }
94
+ };
95
+ return {
96
+ ok: true,
97
+ text
98
+ };
99
+ }
100
+ //#endregion
101
+ //#region src/index.ts
102
+ /**
103
+ * GitHub-issue generation service: builds uniform GitHub issue reports from
104
+ * telemetry analysis, constructs pre-filled issue-creation URLs, and optimizes
105
+ * natural-language issue descriptions with the configured model.
106
+ *
107
+ * Extends `TypertRemoteService` because the client issue-editor UI calls
108
+ * `optimizeIssue` and `prefilledIssueUrl` directly, and the memo log-analysis
109
+ * flow calls `generateReport`.
110
+ *
111
+ * @module @zhangj1164/dsh-github-issue
112
+ */
113
+ var __runInitializers = function(thisArg, initializers, value) {
114
+ var useValue = arguments.length > 2;
115
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
116
+ return useValue ? value : void 0;
117
+ };
118
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
119
+ function accept(f) {
120
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
121
+ return f;
122
+ }
123
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
124
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
125
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
126
+ var _, done = false;
127
+ for (var i = decorators.length - 1; i >= 0; i--) {
128
+ var context = {};
129
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
130
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
131
+ context.addInitializer = function(f) {
132
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
133
+ extraInitializers.push(accept(f || null));
134
+ };
135
+ var result = (0, decorators[i])(kind === "accessor" ? {
136
+ get: descriptor.get,
137
+ set: descriptor.set
138
+ } : descriptor[key], context);
139
+ if (kind === "accessor") {
140
+ if (result === void 0) continue;
141
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
142
+ if (_ = accept(result.get)) descriptor.get = _;
143
+ if (_ = accept(result.set)) descriptor.set = _;
144
+ if (_ = accept(result.init)) initializers.unshift(_);
145
+ } else if (_ = accept(result)) {
146
+ if (kind === "field") initializers.unshift(_);
147
+ else descriptor[key] = _;
148
+ }
149
+ }
150
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
151
+ done = true;
152
+ };
153
+ /** Schemastery configuration for the GitHub-issue service. */
154
+ const Config = s.object({
155
+ repoUrl: s.string().default("https://github.com/zhangj1164/dsh-mega-plugins"),
156
+ maxPrefillUrlLength: s.number().default(7e3),
157
+ prefillTruncationNote: s.string().default("\n\n> (正文过长,已截断以适配 GitHub 的 URL 长度限制。完整正文见备忘面板的「复制完整正文」。)"),
158
+ provider: s.string(),
159
+ model: s.string()
160
+ });
161
+ /** The built-in system prompt that structures a natural-language issue description. */
162
+ const OPTIMIZE_SYSTEM_PROMPT = [
163
+ "You are a technical issue reporter. Rewrite the user's description into a",
164
+ "well-structured GitHub issue following this exact Markdown template.",
165
+ "Fill every section from the description; if a section has no information,",
166
+ "state \"No information provided.\" Do not add information the user did not give.",
167
+ "",
168
+ "The title (first line) must contain Chinese characters.",
169
+ "The visible body must be under 50 units of text; place all detail inside a",
170
+ "default-collapsed <details> block so the issue body stays compact.",
171
+ "",
172
+ "Template:",
173
+ "## <brief Chinese title>",
174
+ "",
175
+ "<details>",
176
+ "<summary>复现、预期与验收</summary>",
177
+ "",
178
+ "- 插件 / Plugin:",
179
+ "- 操作 / Action:",
180
+ "- 预期行为 / Expected:",
181
+ "- 实际行为 / Actual:",
182
+ "- 复现步骤 / Steps to Reproduce:",
183
+ "- 环境 / Environment:",
184
+ "",
185
+ "</details>",
186
+ "",
187
+ "Return only the Markdown body (no code fences around the whole output).",
188
+ "The first line must be a concise title prefixed with \"## \"."
189
+ ].join("\n");
190
+ /** How a fact the analysis did not carry is written, so it reads as absent data. */
191
+ const NOT_RECORDED = "not recorded";
192
+ /**
193
+ * The failure returned when a request arrives without a model route.
194
+ *
195
+ * Named rather than folded into `llm-failure`: a missing route and a model that
196
+ * truly produced nothing are different defects with different owners. Reporting
197
+ * the first as the second sent a user hunting for a model problem in a request
198
+ * that never reached a model.
199
+ */
200
+ const ROUTE_MISSING = {
201
+ ok: false,
202
+ error: {
203
+ code: "route-missing",
204
+ message: "no model route was supplied for this call; the caller must pass the provider and model to use"
205
+ }
206
+ };
207
+ /** The plugin id these events are recorded under, alongside `memo`. */
208
+ const TELEMETRY_PLUGIN_ID = "github-issue";
209
+ /** The DSH machine-routing code `dsh-memo` also uses when nothing resolves a route. */
210
+ const NO_MODEL_ROUTE_CODE = "NO_MODEL_ROUTE";
211
+ /** The built-in system prompt that generates a report from telemetry analysis. */
212
+ const REPORT_SYSTEM_PROMPT = [
213
+ "You are a diagnostic report generator. Given a plugin's telemetry failure",
214
+ "analysis, produce a GitHub issue report that correlates each failure group",
215
+ "against the plugin's feature code. For each group, state the feature-code",
216
+ "anchor, the error code and message, the route and timing the analysis gives,",
217
+ "a likely cause, and a suggested fix.",
218
+ "",
219
+ "Ground every statement in the analysis you were given:",
220
+ "- Cite only facts present in it. Never propose a mechanism the analysis does",
221
+ " not evidence — do not mention sampling parameters, token budgets,",
222
+ " timeouts, chunking, streaming, or response parsing unless the analysis",
223
+ " names that mechanism.",
224
+ "- Report the analysis window, and per failure group the route, the last",
225
+ " failure time, and how many attempts of the same action followed it. Say how",
226
+ " old the last failure is and whether the action ran again afterwards; a",
227
+ " failure that has not recurred is not a current defect.",
228
+ `- Write "${NOT_RECORDED}" for a fact the analysis does not carry. Never write`,
229
+ " that collection needs to be added: a missing fact can be an older event as",
230
+ " easily as a gap, and the report cannot tell which.",
231
+ "",
232
+ "The title (first line) must contain Chinese characters.",
233
+ "Place all detail inside a default-collapsed <details> block.",
234
+ "",
235
+ "Template:",
236
+ "## <brief Chinese title>",
237
+ "",
238
+ "<details>",
239
+ "<summary>诊断报告</summary>",
240
+ "",
241
+ "### 插件 / Plugin",
242
+ "### 操作 / Action",
243
+ "### 预期行为 / Expected",
244
+ "### 实际行为 / Actual",
245
+ "### 路由与时间 / Route and Timing",
246
+ "### 可能原因 / Possible Cause",
247
+ "### 错误日志 / Error Logs",
248
+ "### 环境 / Environment",
249
+ "",
250
+ "</details>",
251
+ "",
252
+ "Return only the Markdown body. The first line must be a concise Chinese title."
253
+ ].join("\n");
254
+ /**
255
+ * GitHub-issue generation and optimization service over the configured model.
256
+ */
257
+ let GithubIssueService = (() => {
258
+ let _classSuper = TypertRemoteService;
259
+ let _instanceExtraInitializers = [];
260
+ let _generateReport_decorators;
261
+ let _prefilledIssueUrl_decorators;
262
+ let _optimizeIssue_decorators;
263
+ return class GithubIssueService extends _classSuper {
264
+ static {
265
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
266
+ _generateReport_decorators = [Remote("generateReport")];
267
+ _prefilledIssueUrl_decorators = [Remote("prefilledIssueUrl")];
268
+ _optimizeIssue_decorators = [Remote("optimizeIssue")];
269
+ __esDecorate(this, null, _generateReport_decorators, {
270
+ kind: "method",
271
+ name: "generateReport",
272
+ static: false,
273
+ private: false,
274
+ access: {
275
+ has: (obj) => "generateReport" in obj,
276
+ get: (obj) => obj.generateReport
277
+ },
278
+ metadata: _metadata
279
+ }, null, _instanceExtraInitializers);
280
+ __esDecorate(this, null, _prefilledIssueUrl_decorators, {
281
+ kind: "method",
282
+ name: "prefilledIssueUrl",
283
+ static: false,
284
+ private: false,
285
+ access: {
286
+ has: (obj) => "prefilledIssueUrl" in obj,
287
+ get: (obj) => obj.prefilledIssueUrl
288
+ },
289
+ metadata: _metadata
290
+ }, null, _instanceExtraInitializers);
291
+ __esDecorate(this, null, _optimizeIssue_decorators, {
292
+ kind: "method",
293
+ name: "optimizeIssue",
294
+ static: false,
295
+ private: false,
296
+ access: {
297
+ has: (obj) => "optimizeIssue" in obj,
298
+ get: (obj) => obj.optimizeIssue
299
+ },
300
+ metadata: _metadata
301
+ }, null, _instanceExtraInitializers);
302
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
303
+ enumerable: true,
304
+ configurable: true,
305
+ writable: true,
306
+ value: _metadata
307
+ });
308
+ }
309
+ static inject = ["llm"];
310
+ static Config = Config;
311
+ repoUrl = __runInitializers(this, _instanceExtraInitializers);
312
+ maxPrefillUrlLength;
313
+ prefillTruncationNote;
314
+ /** Configured provider route, or `undefined` to follow `agentDefaultModel`. */
315
+ provider;
316
+ /** Configured model id, or `undefined` to follow `agentDefaultModel`. */
317
+ model;
318
+ /**
319
+ * @param ctx - Host context carrying the llm service.
320
+ * @param config - Validated deployment configuration.
321
+ */
322
+ constructor(ctx, config) {
323
+ super(ctx, "githubIssue");
324
+ this.repoUrl = config.repoUrl;
325
+ this.maxPrefillUrlLength = config.maxPrefillUrlLength;
326
+ this.prefillTruncationNote = config.prefillTruncationNote;
327
+ this.provider = config.provider;
328
+ this.model = config.model;
329
+ }
330
+ /**
331
+ * Resolve the model route for one model call.
332
+ *
333
+ * The caller's explicit request wins, then this service's validated `Config`,
334
+ * then the deployment's `agentDefaultModel` selection — the same order the
335
+ * memo service uses, so one deployment-level answer decides both. A blank
336
+ * half is treated as absent rather than as a value: it is the shape an
337
+ * omitted field arrives in, and letting it win would send a call with no
338
+ * adapter, whose failure ("the model produced no output") blames the model
339
+ * for a routing problem.
340
+ *
341
+ * @param request - the caller's optional route.
342
+ * @returns the resolved route, or `undefined` when nothing resolved one.
343
+ */
344
+ resolveRoute(request) {
345
+ const selection = this.ctx.get("agentDefaultModel")?.currentSelection();
346
+ const provider = firstNonBlank(request.provider, this.provider, selection?.provider);
347
+ const model = firstNonBlank(request.model, this.model, selection?.model);
348
+ if (provider === void 0 || model === void 0) return void 0;
349
+ return {
350
+ provider,
351
+ model
352
+ };
353
+ }
354
+ /**
355
+ * Generate a uniform GitHub issue report from a telemetry failure analysis.
356
+ * Calls the configured model with a built-in structuring prompt.
357
+ * @param request - the analysis input from the telemetry service.
358
+ * @returns the generated report or an LLM failure.
359
+ */
360
+ async generateReport(request) {
361
+ const route = this.resolveRoute(request);
362
+ if (route === void 0) return this.routeMissing("generateReport");
363
+ const userPrompt = buildReportUserPrompt(request);
364
+ const result = await streamLlmText(this.ctx.get("llm"), route, REPORT_SYSTEM_PROMPT, userPrompt);
365
+ if (!result.ok) return this.llmFailure("generateReport", route, result);
366
+ const title = extractTitle(result.text) ?? `Telemetry failures in ${request.pluginIds.join(", ")}`;
367
+ this.track("generateReport", "success", {
368
+ pluginIds: [...request.pluginIds],
369
+ ...this.routeFacts(route)
370
+ });
371
+ return {
372
+ ok: true,
373
+ value: Object.freeze({
374
+ title,
375
+ body: result.text,
376
+ labels: Object.freeze(["bug", "telemetry"])
377
+ })
378
+ };
379
+ }
380
+ /**
381
+ * Build a pre-filled GitHub issue-creation URL from a report.
382
+ *
383
+ * The body is shortened when the finished URL would exceed the configured
384
+ * limit. The check is on the URL rather than on the body alone because the
385
+ * title and labels spend the same budget and percent-encoding inflates it,
386
+ * so a body-length rule would still produce a URL GitHub refuses.
387
+ *
388
+ * @param request - the repository URL and the report to prefill.
389
+ * @returns the prefill URL or an invalid-URL failure.
390
+ */
391
+ prefilledIssueUrl(request) {
392
+ const repoUrl = request.repoUrl.length > 0 ? request.repoUrl : this.repoUrl;
393
+ if (!/^https:\/\/github\.com\/[^/]+\/[^/]+$/u.test(repoUrl)) return {
394
+ ok: false,
395
+ error: {
396
+ code: "invalid-url",
397
+ message: `repoUrl "${repoUrl}" is not a valid GitHub repository URL`
398
+ }
399
+ };
400
+ return {
401
+ ok: true,
402
+ value: buildPrefillUrl(repoUrl, request.report, this.maxPrefillUrlLength, this.prefillTruncationNote)
403
+ };
404
+ }
405
+ /**
406
+ * Optimize a natural-language issue description into a structured report
407
+ * using the configured model and a built-in structuring prompt.
408
+ * @param request - the raw description and model route.
409
+ * @returns the structured report or an LLM failure.
410
+ */
411
+ async optimizeIssue(request) {
412
+ if (request.description.trim().length === 0) return {
413
+ ok: false,
414
+ error: {
415
+ code: "empty-input",
416
+ message: "the issue description is empty"
417
+ }
418
+ };
419
+ const route = this.resolveRoute(request);
420
+ if (route === void 0) return this.routeMissing("optimizeIssue");
421
+ const result = await streamLlmText(this.ctx.get("llm"), route, OPTIMIZE_SYSTEM_PROMPT, request.description);
422
+ if (!result.ok) return this.llmFailure("optimizeIssue", route, result);
423
+ const title = extractTitle(result.text) ?? "New issue";
424
+ this.track("optimizeIssue", "success", this.routeFacts(route));
425
+ return {
426
+ ok: true,
427
+ value: Object.freeze({
428
+ title,
429
+ body: result.text,
430
+ labels: Object.freeze(["bug"])
431
+ })
432
+ };
433
+ }
434
+ /**
435
+ * Report one model failure and return the business failure.
436
+ *
437
+ * The preserved DSH code goes into both the message and the telemetry event:
438
+ * the message is what a user reads, and the event is what a later analysis
439
+ * groups on. Reporting either as a bare "the model produced no output" is how
440
+ * an unroutable provider was mistaken for a model that answered nothing.
441
+ *
442
+ * @param action - the method the failure happened in, and its telemetry action.
443
+ * @param route - the route the call was sent to.
444
+ * @param failure - the preserved DSH failure facts.
445
+ * @returns the business failure to return to the caller.
446
+ */
447
+ llmFailure(action, route, failure) {
448
+ const detail = failure.failure;
449
+ const message = `model call to provider "${route.provider}" model "${route.model}" failed: ${detail.code}: ${detail.message}`;
450
+ this.trackError(action, {
451
+ code: detail.code,
452
+ message: detail.message,
453
+ featureCodeRef: `${TELEMETRY_PLUGIN_ID}:${action}`
454
+ }, {
455
+ ...this.routeFacts(route),
456
+ ...detail.status === void 0 ? {} : { status: detail.status }
457
+ });
458
+ return {
459
+ ok: false,
460
+ error: {
461
+ code: "llm-failure",
462
+ message
463
+ }
464
+ };
465
+ }
466
+ /**
467
+ * Report a call that arrived with no resolvable route.
468
+ *
469
+ * Recorded as a failure rather than silently skipped: this is the defect that
470
+ * produced an empty model response from every click of the issue editor's
471
+ * optimize button, and it left no trace anywhere, so it was found by a user
472
+ * report instead of by the telemetry it should have written.
473
+ *
474
+ * @param action - the method the call was made to, and its telemetry action.
475
+ * @returns the business failure to return to the caller.
476
+ */
477
+ routeMissing(action) {
478
+ this.trackError(action, {
479
+ code: NO_MODEL_ROUTE_CODE,
480
+ message: ROUTE_MISSING.error.message,
481
+ featureCodeRef: `${TELEMETRY_PLUGIN_ID}:${action}`
482
+ }, {});
483
+ return ROUTE_MISSING;
484
+ }
485
+ /**
486
+ * The route facts every AI-related telemetry event carries.
487
+ *
488
+ * Success events carry them for the same reason failures do: a record that
489
+ * names the route only when it broke cannot say which route served the calls
490
+ * that worked, so a later analysis cannot tell whether the route changed.
491
+ *
492
+ * @param route - the route the call used.
493
+ * @returns the provider and model to merge into an event's metadata.
494
+ */
495
+ routeFacts(route) {
496
+ return {
497
+ provider: route.provider,
498
+ model: route.model
499
+ };
500
+ }
501
+ /**
502
+ * Record one completed call, when this deployment has telemetry mounted.
503
+ *
504
+ * @param action - the method the call was made to.
505
+ * @param result - whether it succeeded.
506
+ * @param metadata - structured context, such as the route.
507
+ */
508
+ track(action, result, metadata) {
509
+ this.ctx.get("telemetry")?.track({
510
+ pluginId: TELEMETRY_PLUGIN_ID,
511
+ action,
512
+ result,
513
+ ...metadata === void 0 ? {} : { metadata }
514
+ });
515
+ }
516
+ /**
517
+ * Record one failure, when this deployment has telemetry mounted.
518
+ *
519
+ * @param action - the method the failure happened in.
520
+ * @param error - the failure code, message, and feature anchor.
521
+ * @param metadata - structured context, such as the attempted route.
522
+ */
523
+ trackError(action, error, metadata) {
524
+ this.ctx.get("telemetry")?.trackError({
525
+ pluginId: TELEMETRY_PLUGIN_ID,
526
+ action,
527
+ error,
528
+ ...metadata === void 0 ? {} : { metadata }
529
+ });
530
+ }
531
+ };
532
+ })();
533
+ /**
534
+ * The first argument that carries a non-blank value.
535
+ *
536
+ * @param values - candidate values, most specific first.
537
+ * @returns the trimmed value, or `undefined` when all are blank or absent.
538
+ */
539
+ function firstNonBlank(...values) {
540
+ for (const value of values) if (typeof value === "string" && value.trim().length > 0) return value.trim();
541
+ }
542
+ /**
543
+ * Build the user-facing prompt from a telemetry analysis, listing every
544
+ * failure group with its feature-code anchor, error code, and message.
545
+ *
546
+ * The facts are stated flatly and dated: a report that receives only totals and
547
+ * error codes can say nothing about whether a failure still happens, and a model
548
+ * asked to explain it will invent mechanisms the plugin does not have. Every
549
+ * line here is one the plugin actually recorded, and a missing fact is labelled
550
+ * as missing rather than omitted, so the model can tell "not recorded" from
551
+ * "nothing to report".
552
+ *
553
+ * @param request - the analysis input.
554
+ * @returns the structured prompt text.
555
+ */
556
+ function buildReportUserPrompt(request) {
557
+ const groups = request.failureGroups.map((group) => {
558
+ const lines = [`- featureCodeRef: ${group.featureCodeRef}`, ` count: ${group.count}`];
559
+ if (group.errorCode !== void 0) lines.push(` errorCode: ${group.errorCode}`);
560
+ if (group.errorMessage !== void 0) lines.push(` errorMessage: ${group.errorMessage}`);
561
+ lines.push(` route: ${group.route === void 0 ? NOT_RECORDED : `${group.route.provider} / ${group.route.model}${group.route.status === void 0 ? "" : ` (status ${group.route.status})`}`}`);
562
+ lines.push(` lastFailureAt: ${group.lastFailureAt === void 0 ? NOT_RECORDED : new Date(group.lastFailureAt).toISOString()}`);
563
+ lines.push(` attemptsAfterLastFailure: ${group.attemptsAfterLastFailure ?? NOT_RECORDED}`);
564
+ return lines.join("\n");
565
+ }).join("\n");
566
+ const window = request.window === void 0 ? `Analysis window: ${NOT_RECORDED}` : `Analysis window: ${new Date(request.window.firstEventAt).toISOString()} .. ${new Date(request.window.lastEventAt).toISOString()}`;
567
+ return [
568
+ `Plugins: ${request.pluginIds.length === 0 ? NOT_RECORDED : request.pluginIds.join(", ")}`,
569
+ window,
570
+ `Total events: ${request.totalEvents}`,
571
+ `Total failures: ${request.totalFailures}`,
572
+ "",
573
+ "Failure groups:",
574
+ groups.length === 0 ? "(none)" : groups
575
+ ].join("\n");
576
+ }
577
+ /**
578
+ * Extract a title from the first Markdown heading of the body, if present.
579
+ * @param body - the Markdown body.
580
+ * @returns the heading text without the `## ` prefix, or `undefined`.
581
+ */
582
+ function extractTitle(body) {
583
+ return /^##\s+(.+?)$/mu.exec(body)?.[1]?.trim();
584
+ }
585
+ /**
586
+ * Upper bound on the shortening passes in {@link buildPrefillUrl}. Each pass
587
+ * removes at least the overflow it measured, so a handful of passes converge;
588
+ * the bound exists only so a pathological input cannot spin.
589
+ */
590
+ const MAX_PREFILL_TRUNCATION_PASSES = 32;
591
+ /**
592
+ * Build the pre-filled new-issue URL, shortening the body so the URL stays
593
+ * within `maxLength`.
594
+ *
595
+ * The budget is checked on the composed URL rather than on the body alone,
596
+ * because the title and labels spend the same budget and percent-encoding
597
+ * inflates whatever they contain; a body-length rule would still emit a URL
598
+ * GitHub refuses. Each pass removes at least the overflow it measured, which
599
+ * can overshoot when characters encode to more than one URL character, and
600
+ * overshooting only shortens the body further.
601
+ *
602
+ * @param repoUrl - the repository URL, already validated by the caller.
603
+ * @param report - the report to prefill.
604
+ * @param maxLength - the longest acceptable URL; `0` or less disables shortening.
605
+ * @param note - appended to the body when shortening was necessary.
606
+ * @returns the pre-filled issue URL.
607
+ */
608
+ function buildPrefillUrl(repoUrl, report, maxLength, note) {
609
+ const compose = (body) => {
610
+ const params = new URLSearchParams();
611
+ params.set("title", report.title);
612
+ params.set("body", body);
613
+ if (report.labels.length > 0) params.set("labels", report.labels.join(","));
614
+ return `${repoUrl}/issues/new?${params.toString()}`;
615
+ };
616
+ const full = compose(report.body);
617
+ if (maxLength <= 0 || full.length <= maxLength) return full;
618
+ let keep = report.body.length;
619
+ let candidate = compose(`${report.body.slice(0, keep)}${note}`);
620
+ for (let pass = 0; pass < MAX_PREFILL_TRUNCATION_PASSES && candidate.length > maxLength && keep > 0; pass += 1) {
621
+ keep = Math.max(0, keep - Math.max(1, candidate.length - maxLength));
622
+ candidate = compose(`${report.body.slice(0, keep)}${note}`);
623
+ }
624
+ return candidate.length <= maxLength ? candidate : compose(note);
625
+ }
626
+ //#endregion
627
+ export { Config, GithubIssueService, GithubIssueService as default };
@@ -0,0 +1,14 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ //#region src/invariant.d.ts
3
+ /** Cordis companion plugin name. */
4
+ declare const name = "github-issue-invariant";
5
+ /** Services required before the companion can reserve and check package ownership. */
6
+ declare const inject: string[];
7
+ /**
8
+ * Register this package's invariant companion.
9
+ * @param ctx - Cordis context carrying the invariant service.
10
+ * @returns the installed registration's disposer after setup succeeds.
11
+ */
12
+ declare const apply: (ctx: Context) => Promise<() => void>;
13
+ //#endregion
14
+ export { apply, inject, name };
@@ -0,0 +1,19 @@
1
+ //#region src/invariant.ts
2
+ const PACKAGE_NAME = "@zhangj1164/dsh-github-issue";
3
+ /** Cordis companion plugin name. */
4
+ const name = "github-issue-invariant";
5
+ /** Services required before the companion can reserve and check package ownership. */
6
+ const inject = ["invariants"];
7
+ /**
8
+ * No runtime invariant: the service owns its model calls and URL construction
9
+ * deterministically, and no second authority exists.
10
+ */
11
+ const install = Object.assign(() => {}, { inject: ["githubIssue"] });
12
+ /**
13
+ * Register this package's invariant companion.
14
+ * @param ctx - Cordis context carrying the invariant service.
15
+ * @returns the installed registration's disposer after setup succeeds.
16
+ */
17
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
18
+ //#endregion
19
+ export { apply, inject, name };