@zhangj1164/dsh-github-issue 0.1.0 → 0.3.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 CHANGED
@@ -1,6 +1,435 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { Service } from "@deepseek-ai/cordis";
1
4
  import s from "@deepseek-ai/schemastery";
2
5
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
6
+ import { z } from "zod";
3
7
  import { EMPTY_RESPONSE_CODE, createUserMessage } from "@deepseek-ai/dsh-llm";
8
+ //#region src/github.ts
9
+ /**
10
+ * The GitHub REST API version this client speaks.
11
+ *
12
+ * Pinned rather than configurable: it is the protocol the request shape was
13
+ * written against, and a deployment that changed it would silently receive a
14
+ * different response schema.
15
+ */
16
+ const GITHUB_API_VERSION = "2022-11-28";
17
+ /** User-Agent GitHub requires on every REST request. */
18
+ const USER_AGENT = "@zhangj1164/dsh-github-issue";
19
+ /** Repository URLs of the form `https://host/owner/repo`, with one optional trailing slash. */
20
+ const REPOSITORY_URL = /^https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+?)\/?$/u;
21
+ /**
22
+ * Split a repository URL into owner and name.
23
+ * @param repoUrl - the configured repository URL.
24
+ * @returns the repository parts, or `undefined` when the URL is not one.
25
+ */
26
+ function parseRepository(repoUrl) {
27
+ const match = REPOSITORY_URL.exec(repoUrl.trim());
28
+ if (match?.[1] === void 0 || match[2] === void 0) return void 0;
29
+ return {
30
+ owner: match[1],
31
+ repo: match[2]
32
+ };
33
+ }
34
+ /**
35
+ * GitHub issue intake over the REST API.
36
+ *
37
+ * The class owns request construction, authentication, and response
38
+ * classification. It holds no state and caches nothing, so one instance can
39
+ * serve every repository a deployment configures.
40
+ */
41
+ var GithubIssueClient = class {
42
+ repoUrl;
43
+ repository;
44
+ apiBaseUrl;
45
+ token;
46
+ fetchImpl;
47
+ timeoutMs;
48
+ /**
49
+ * @param options - repository, credential, and transport policy.
50
+ */
51
+ constructor(options) {
52
+ this.repoUrl = options.repoUrl;
53
+ this.repository = parseRepository(options.repoUrl);
54
+ this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/u, "");
55
+ this.token = options.token;
56
+ this.fetchImpl = options.fetchImpl ?? fetch;
57
+ this.timeoutMs = options.timeoutMs;
58
+ }
59
+ /**
60
+ * Create one issue in the configured repository.
61
+ *
62
+ * @param report - the optimized issue report; its title becomes the issue
63
+ * title and its body the issue body.
64
+ * @returns the created issue's number and URL, or a stable failure.
65
+ */
66
+ async createIssue(report) {
67
+ const repository = this.repository;
68
+ if (repository === void 0) return this.fail("invalid-repo", `"${this.repoUrl}" is not a repository URL`);
69
+ if (this.token === void 0) return this.fail("auth-missing", "no GitHub token is configured; set the configured token environment variable or token command");
70
+ const title = report.title.trim();
71
+ if (title.length === 0) return this.fail("invalid-request", "the issue report has no title");
72
+ const response = await this.send({
73
+ method: "POST",
74
+ path: `/repos/${repository.owner}/${repository.repo}/issues`,
75
+ body: {
76
+ title,
77
+ body: report.body,
78
+ labels: [...report.labels]
79
+ }
80
+ });
81
+ if (!response.ok) return response;
82
+ const payload = response.value;
83
+ const number = readNumber(payload, "number");
84
+ const url = readString(payload, "html_url");
85
+ if (number === void 0 || url === void 0) return this.fail("http-failure", "GitHub accepted the issue but the response carried no number or URL");
86
+ return {
87
+ ok: true,
88
+ value: Object.freeze({
89
+ number,
90
+ url
91
+ })
92
+ };
93
+ }
94
+ /**
95
+ * List open issues carrying one claim label.
96
+ *
97
+ * Pull requests are excluded explicitly: GitHub's issue endpoints return
98
+ * them, and a pull request is never an issue a session should claim.
99
+ *
100
+ * @param label - the claim label every returned issue carries.
101
+ * @param max - the largest number of issues to request.
102
+ * @returns the matching issues, or a stable failure.
103
+ */
104
+ async listClaimable(label, max) {
105
+ const repository = this.repository;
106
+ if (repository === void 0) return this.fail("invalid-repo", `"${this.repoUrl}" is not a repository URL`);
107
+ if (this.token === void 0) return this.fail("auth-missing", "no GitHub token is configured; set the configured token environment variable or token command");
108
+ const query = new URLSearchParams({
109
+ state: "open",
110
+ labels: label,
111
+ per_page: String(max)
112
+ });
113
+ const response = await this.send({
114
+ method: "GET",
115
+ path: `/repos/${repository.owner}/${repository.repo}/issues?${query.toString()}`
116
+ });
117
+ if (!response.ok) return response;
118
+ if (!Array.isArray(response.value)) return this.fail("http-failure", "GitHub returned a list response that is not an array");
119
+ const tasks = [];
120
+ for (const entry of response.value) {
121
+ const task = asTask(entry, label);
122
+ if (task !== void 0) tasks.push(task);
123
+ }
124
+ return {
125
+ ok: true,
126
+ value: Object.freeze(tasks)
127
+ };
128
+ }
129
+ /**
130
+ * Send one request and decode its JSON body.
131
+ * @param request - verb, path, and optional body.
132
+ * @returns the decoded payload or a classified failure.
133
+ */
134
+ async send(request) {
135
+ let response;
136
+ try {
137
+ response = await this.fetchImpl(`${this.apiBaseUrl}${request.path}`, {
138
+ method: request.method,
139
+ headers: this.headers(),
140
+ ...request.body === void 0 ? {} : { body: JSON.stringify(request.body) },
141
+ signal: AbortSignal.timeout(this.timeoutMs)
142
+ });
143
+ } catch (error) {
144
+ return this.fail("network-failure", `the request to GitHub failed: ${describeError$1(error)}`);
145
+ }
146
+ if (!response.ok) return this.httpFailure(response);
147
+ try {
148
+ const text = await response.text();
149
+ return {
150
+ ok: true,
151
+ value: text.trim().length === 0 ? {} : JSON.parse(text)
152
+ };
153
+ } catch (error) {
154
+ return this.fail("http-failure", `GitHub returned a response that could not be read: ${describeError$1(error)}`);
155
+ }
156
+ }
157
+ /**
158
+ * Classify one non-success HTTP response.
159
+ * @param response - the failed response.
160
+ * @returns the stable failure for its status.
161
+ */
162
+ async httpFailure(response) {
163
+ const detail = await readErrorMessage(response);
164
+ if (response.status === 401) return this.fail("auth-failure", `GitHub rejected the token (401)${detail}`);
165
+ if (response.status === 403) {
166
+ if (response.headers.get("x-ratelimit-remaining") === "0") return this.fail("rate-limited", `GitHub rate limit exhausted (403)${detail}`);
167
+ return this.fail("auth-failure", `GitHub refused the request (403)${detail}`);
168
+ }
169
+ if (response.status === 422) return this.fail("invalid-request", `GitHub rejected the request as invalid (422)${detail}`);
170
+ return this.fail("http-failure", `GitHub returned HTTP ${response.status}${detail}`);
171
+ }
172
+ /**
173
+ * The headers every request carries.
174
+ * @returns the header map, including the bearer token when one is configured.
175
+ */
176
+ headers() {
177
+ const headers = {
178
+ accept: "application/vnd.github+json",
179
+ "content-type": "application/json",
180
+ "user-agent": USER_AGENT,
181
+ "x-github-api-version": GITHUB_API_VERSION
182
+ };
183
+ if (this.token !== void 0) headers.authorization = `Bearer ${this.token}`;
184
+ return headers;
185
+ }
186
+ /**
187
+ * Build one failure result.
188
+ * @param code - stable failure code.
189
+ * @param message - user-facing explanation.
190
+ * @returns the failure result.
191
+ */
192
+ fail(code, message) {
193
+ return {
194
+ ok: false,
195
+ error: {
196
+ code,
197
+ message
198
+ }
199
+ };
200
+ }
201
+ };
202
+ /**
203
+ * Read a string member from an unknown JSON object.
204
+ * @param value - decoded JSON.
205
+ * @param key - member name.
206
+ * @returns the string value, or `undefined`.
207
+ */
208
+ function readString(value, key) {
209
+ if (!isRecord(value)) return void 0;
210
+ const member = value[key];
211
+ return typeof member === "string" ? member : void 0;
212
+ }
213
+ /**
214
+ * Read a numeric member from an unknown JSON object.
215
+ * @param value - decoded JSON.
216
+ * @param key - member name.
217
+ * @returns the number value, or `undefined`.
218
+ */
219
+ function readNumber(value, key) {
220
+ if (!isRecord(value)) return void 0;
221
+ const member = value[key];
222
+ return typeof member === "number" && Number.isFinite(member) ? member : void 0;
223
+ }
224
+ /**
225
+ * Narrow one decoded API entry into a claimable task.
226
+ *
227
+ * Malformed entries and pull requests are skipped rather than failing the whole
228
+ * list: one unexpected row must not hide every real task behind it.
229
+ *
230
+ * @param entry - one array member of the issues response.
231
+ * @param label - the claim label the entry must carry.
232
+ * @returns the task, or `undefined` when the entry is not claimable.
233
+ */
234
+ function asTask(entry, label) {
235
+ if (!isRecord(entry)) return void 0;
236
+ if ("pull_request" in entry) return void 0;
237
+ const number = readNumber(entry, "number");
238
+ const title = readString(entry, "title");
239
+ const url = readString(entry, "html_url");
240
+ if (number === void 0 || title === void 0 || url === void 0) return void 0;
241
+ const labels = readLabels(entry["labels"]);
242
+ if (!labels.some((name) => name.toLowerCase() === label.toLowerCase())) return void 0;
243
+ const body = readString(entry, "body") ?? "";
244
+ return Object.freeze({
245
+ number,
246
+ title,
247
+ body,
248
+ url,
249
+ labels: Object.freeze(labels)
250
+ });
251
+ }
252
+ /**
253
+ * Read the label names of one issue.
254
+ * @param value - untyped `labels` member.
255
+ * @returns the non-empty names, in response order.
256
+ */
257
+ function readLabels(value) {
258
+ if (!Array.isArray(value)) return [];
259
+ const names = [];
260
+ for (const entry of value) {
261
+ const name = isRecord(entry) ? readString(entry, "name") : void 0;
262
+ if (name !== void 0 && name.length > 0) names.push(name);
263
+ }
264
+ return names;
265
+ }
266
+ /**
267
+ * Read the message GitHub attached to a failed response.
268
+ * @param response - the failed response.
269
+ * @returns `: message` for the failure text, or an empty string.
270
+ */
271
+ async function readErrorMessage(response) {
272
+ try {
273
+ const text = await response.text();
274
+ if (text.length === 0) return "";
275
+ const parsed = JSON.parse(text);
276
+ const message = isRecord(parsed) ? readString(parsed, "message") : void 0;
277
+ return message === void 0 || message.length === 0 ? "" : `: ${message}`;
278
+ } catch {
279
+ return "";
280
+ }
281
+ }
282
+ /**
283
+ * Report whether an unknown value is a plain object.
284
+ * @param value - candidate value.
285
+ * @returns true for a non-null, non-array object.
286
+ */
287
+ function isRecord(value) {
288
+ return typeof value === "object" && value !== null && !Array.isArray(value);
289
+ }
290
+ /**
291
+ * Render an unknown thrown value as a message.
292
+ * @param error - the thrown value.
293
+ * @returns a non-empty message.
294
+ */
295
+ function describeError$1(error) {
296
+ if (error instanceof Error) return error.message;
297
+ return String(error);
298
+ }
299
+ //#endregion
300
+ //#region src/intake.ts
301
+ /**
302
+ * The dispatch prompt a deployment gets when it configures none.
303
+ *
304
+ * The placeholders are documented on {@link renderDispatchPrompt}; the text
305
+ * tells the receiving Session where the issue is and which workflow to follow,
306
+ * without assuming a repository-specific branch naming scheme.
307
+ */
308
+ const DEFAULT_DISPATCH_PROMPT = [
309
+ "你在处理 GitHub issue #{number}:{title}",
310
+ "",
311
+ "{url}",
312
+ "",
313
+ "请按仓库 AGENTS.md 的工作流完成它:从最新 main 切分支、实现并补测试、跑通门禁、开 PR 并引用 #{number}。不要直接提交到 main。",
314
+ "",
315
+ "Issue 正文:",
316
+ "{body}"
317
+ ].join("\n");
318
+ /** Placeholders {@link renderDispatchPrompt} replaces. */
319
+ const PLACEHOLDER = /\{(number|title|url|body|labels)\}/gu;
320
+ /**
321
+ * Strip the leading `## title` line from an optimized report body.
322
+ *
323
+ * The optimizer repeats the title as the body's first heading so the same text
324
+ * can drive the pre-filled URL form. GitHub takes the title as its own field,
325
+ * so filing that body unchanged would show the title twice.
326
+ *
327
+ * @param report - the optimized report.
328
+ * @returns the body without its leading title heading.
329
+ */
330
+ function issueBody(report) {
331
+ const lines = report.body.replace(/\r\n/gu, "\n").split("\n");
332
+ if (/^##\s+/u.test(lines[0] ?? "")) {
333
+ lines.shift();
334
+ while (lines.length > 0 && (lines[0] ?? "").trim().length === 0) lines.shift();
335
+ }
336
+ return lines.join("\n").trimEnd();
337
+ }
338
+ /**
339
+ * Render the dispatch prompt for one issue.
340
+ *
341
+ * Supported placeholders are `{number}`, `{title}`, `{url}`, `{body}`, and
342
+ * `{labels}`. An unknown `{token}` is left untouched rather than dropped, so a
343
+ * typo in a deployment template stays visible instead of silently disappearing.
344
+ *
345
+ * @param template - the configured prompt template.
346
+ * @param issue - the issue being dispatched.
347
+ * @returns the prompt the receiving Session reads.
348
+ */
349
+ function renderDispatchPrompt(template, issue) {
350
+ const values = {
351
+ number: String(issue.number),
352
+ title: issue.title,
353
+ url: issue.url,
354
+ body: issue.body,
355
+ labels: issue.labels.join(", ")
356
+ };
357
+ return template.replace(PLACEHOLDER, (_match, key) => values[key] ?? "");
358
+ }
359
+ /**
360
+ * Format one instant as a local calendar day.
361
+ *
362
+ * The key is the deployment's local date, not UTC's: "today" for a user in
363
+ * Asia/Shanghai starts eight hours before UTC's, and a UTC key would let a late
364
+ * restart skip the day or run the check twice.
365
+ *
366
+ * @param now - instant in epoch milliseconds.
367
+ * @param timeZone - IANA zone; an empty value follows the host's own zone.
368
+ * @returns the `YYYY-MM-DD` local date.
369
+ */
370
+ function localDayKey(now, timeZone) {
371
+ const parts = new Intl.DateTimeFormat("en-US", {
372
+ ...timeZone.length === 0 ? {} : { timeZone },
373
+ year: "numeric",
374
+ month: "2-digit",
375
+ day: "2-digit"
376
+ }).formatToParts(new Date(now));
377
+ const read = (type) => parts.find((part) => part.type === type)?.value ?? "";
378
+ return `${read("year")}-${read("month")}-${read("day")}`;
379
+ }
380
+ /**
381
+ * Verify that one IANA time-zone name is usable.
382
+ *
383
+ * Called while the service configures itself, so a typo fails at load with a
384
+ * named field instead of at the first startup check, hours later.
385
+ *
386
+ * @param timeZone - candidate zone; an empty value is always valid.
387
+ * @returns true when the zone resolves.
388
+ */
389
+ function isValidTimeZone(timeZone) {
390
+ if (timeZone.length === 0) return true;
391
+ try {
392
+ new Intl.DateTimeFormat("en-US", { timeZone });
393
+ return true;
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+ //#endregion
399
+ //#region src/spec.ts
400
+ /** Table key of the single intake state row. */
401
+ const INTAKE_STATE_KEY = "intake";
402
+ /** Runtime schema for one dispatched issue. */
403
+ const intakeDispatchedSchema = z.object({
404
+ number: z.number().int().positive(),
405
+ day: z.string().min(1),
406
+ sessionId: z.string().min(1)
407
+ });
408
+ /** Runtime schema for the persisted intake state. */
409
+ const intakeStateSchema = z.object({
410
+ lastCheckedDay: z.string(),
411
+ dispatched: z.array(intakeDispatchedSchema)
412
+ });
413
+ /**
414
+ * Build the storage-domain form of the intake state, on demand.
415
+ *
416
+ * The import is dynamic so this package stays loadable without
417
+ * `@deepseek-ai/dsh-storage-domain`: that package is an optional peer, and a
418
+ * deployment that does not ship it must lose the daily check, not the whole
419
+ * plugin. Every DSH installation includes it, so the failure path is a
420
+ * defence rather than a routine route.
421
+ *
422
+ * @returns the declared domain, ready for `storageDomain.open`.
423
+ */
424
+ async function createIntakeDomain() {
425
+ const { defineDomain, domainTable } = await import("@deepseek-ai/dsh-storage-domain");
426
+ return defineDomain({
427
+ name: "github_issue_intake",
428
+ version: 0,
429
+ tables: { state: domainTable(intakeStateSchema) }
430
+ });
431
+ }
432
+ //#endregion
4
433
  //#region src/llm-text.ts
5
434
  /**
6
435
  * Model-text helper for this service: ask a model for one block of text and
@@ -152,11 +581,24 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
152
581
  };
153
582
  /** Schemastery configuration for the GitHub-issue service. */
154
583
  const Config = s.object({
155
- repoUrl: s.string().default("https://github.com/zhangj1164/dsh-mega-plugins"),
584
+ repoUrl: s.string().default("https://github.com/zhangj1164/dsh-mega-plugins").volatile(),
156
585
  maxPrefillUrlLength: s.number().default(7e3),
157
586
  prefillTruncationNote: s.string().default("\n\n> (正文过长,已截断以适配 GitHub 的 URL 长度限制。完整正文见备忘面板的「复制完整正文」。)"),
158
587
  provider: s.string(),
159
- model: s.string()
588
+ model: s.string(),
589
+ tokenEnv: s.string().default("GITHUB_TOKEN"),
590
+ token: s.string(),
591
+ tokenCommand: s.string().default("gh auth token"),
592
+ tokenCommandTimeoutMs: s.number().step(1).min(1e3).max(12e4).default(1e4),
593
+ apiBaseUrl: s.string().default("https://api.github.com"),
594
+ requestTimeoutMs: s.number().step(1).min(1e3).max(18e4).default(15e3),
595
+ claimLabel: s.string().default("ready-for-agent").volatile(),
596
+ maxIssuesPerRun: s.number().step(1).min(1).max(20).default(3).volatile(),
597
+ autopilotEnabled: s.boolean().default(false).volatile(),
598
+ autopilotCwd: s.string().default("").volatile(),
599
+ autopilotPreset: s.string().default("").volatile(),
600
+ autopilotTimeZone: s.string().default("").volatile(),
601
+ autopilotPrompt: s.string().default(DEFAULT_DISPATCH_PROMPT).volatile()
160
602
  });
161
603
  /** The built-in system prompt that structures a natural-language issue description. */
162
604
  const OPTIMIZE_SYSTEM_PROMPT = [
@@ -251,8 +693,35 @@ const REPORT_SYSTEM_PROMPT = [
251
693
  "",
252
694
  "Return only the Markdown body. The first line must be a concise Chinese title."
253
695
  ].join("\n");
696
+ /** The empty intake state used before the first pass and without storage. */
697
+ const EMPTY_INTAKE_STATE = Object.freeze({
698
+ lastCheckedDay: "",
699
+ dispatched: Object.freeze([])
700
+ });
701
+ /** The failure returned when a report cannot become a GitHub issue at all. */
702
+ const INVALID_ISSUE_REQUEST = {
703
+ ok: false,
704
+ error: {
705
+ code: "invalid-request",
706
+ message: "the issue report must carry a non-empty title and a body"
707
+ }
708
+ };
709
+ /** The failure returned when the deployment has no Session router. */
710
+ const SESSION_UNAVAILABLE = {
711
+ ok: false,
712
+ error: {
713
+ code: "session-unavailable",
714
+ message: "this deployment mounts no Session controller or Agent registry, so no issue can be dispatched"
715
+ }
716
+ };
717
+ /** Promise-based `exec`, used only for the configured token command. */
718
+ const execAsync = promisify(exec);
254
719
  /**
255
720
  * GitHub-issue generation and optimization service over the configured model.
721
+ *
722
+ * Beyond the model-backed report methods, the service owns the issue intake
723
+ * loop: it files an optimized report through the GitHub REST API, lists the
724
+ * issues that carry the claim label, and hands one of them to a fresh Session.
256
725
  */
257
726
  let GithubIssueService = (() => {
258
727
  let _classSuper = TypertRemoteService;
@@ -260,12 +729,20 @@ let GithubIssueService = (() => {
260
729
  let _generateReport_decorators;
261
730
  let _prefilledIssueUrl_decorators;
262
731
  let _optimizeIssue_decorators;
732
+ let _createIssue_decorators;
733
+ let _listClaimableIssues_decorators;
734
+ let _dispatchIssue_decorators;
735
+ let _runIntake_decorators;
263
736
  return class GithubIssueService extends _classSuper {
264
737
  static {
265
738
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
266
739
  _generateReport_decorators = [Remote("generateReport")];
267
740
  _prefilledIssueUrl_decorators = [Remote("prefilledIssueUrl")];
268
741
  _optimizeIssue_decorators = [Remote("optimizeIssue")];
742
+ _createIssue_decorators = [Remote("createIssue")];
743
+ _listClaimableIssues_decorators = [Remote("listClaimableIssues")];
744
+ _dispatchIssue_decorators = [Remote("dispatchIssue")];
745
+ _runIntake_decorators = [Remote("runIntake")];
269
746
  __esDecorate(this, null, _generateReport_decorators, {
270
747
  kind: "method",
271
748
  name: "generateReport",
@@ -299,6 +776,50 @@ let GithubIssueService = (() => {
299
776
  },
300
777
  metadata: _metadata
301
778
  }, null, _instanceExtraInitializers);
779
+ __esDecorate(this, null, _createIssue_decorators, {
780
+ kind: "method",
781
+ name: "createIssue",
782
+ static: false,
783
+ private: false,
784
+ access: {
785
+ has: (obj) => "createIssue" in obj,
786
+ get: (obj) => obj.createIssue
787
+ },
788
+ metadata: _metadata
789
+ }, null, _instanceExtraInitializers);
790
+ __esDecorate(this, null, _listClaimableIssues_decorators, {
791
+ kind: "method",
792
+ name: "listClaimableIssues",
793
+ static: false,
794
+ private: false,
795
+ access: {
796
+ has: (obj) => "listClaimableIssues" in obj,
797
+ get: (obj) => obj.listClaimableIssues
798
+ },
799
+ metadata: _metadata
800
+ }, null, _instanceExtraInitializers);
801
+ __esDecorate(this, null, _dispatchIssue_decorators, {
802
+ kind: "method",
803
+ name: "dispatchIssue",
804
+ static: false,
805
+ private: false,
806
+ access: {
807
+ has: (obj) => "dispatchIssue" in obj,
808
+ get: (obj) => obj.dispatchIssue
809
+ },
810
+ metadata: _metadata
811
+ }, null, _instanceExtraInitializers);
812
+ __esDecorate(this, null, _runIntake_decorators, {
813
+ kind: "method",
814
+ name: "runIntake",
815
+ static: false,
816
+ private: false,
817
+ access: {
818
+ has: (obj) => "runIntake" in obj,
819
+ get: (obj) => obj.runIntake
820
+ },
821
+ metadata: _metadata
822
+ }, null, _instanceExtraInitializers);
302
823
  if (_metadata) Object.defineProperty(this, Symbol.metadata, {
303
824
  enumerable: true,
304
825
  configurable: true,
@@ -308,24 +829,82 @@ let GithubIssueService = (() => {
308
829
  }
309
830
  static inject = ["llm"];
310
831
  static Config = Config;
311
- repoUrl = __runInitializers(this, _instanceExtraInitializers);
312
- maxPrefillUrlLength;
832
+ maxPrefillUrlLength = __runInitializers(this, _instanceExtraInitializers);
313
833
  prefillTruncationNote;
314
834
  /** Configured provider route, or `undefined` to follow `agentDefaultModel`. */
315
835
  provider;
316
836
  /** Configured model id, or `undefined` to follow `agentDefaultModel`. */
317
837
  model;
838
+ tokenEnv;
839
+ token;
840
+ tokenCommand;
841
+ tokenCommandTimeoutMs;
842
+ apiBaseUrl;
843
+ requestTimeoutMs;
844
+ /** The persisted state, or the empty state when this deployment has no storage. */
845
+ intakeState = EMPTY_INTAKE_STATE;
846
+ /** The durable table, absent when this deployment mounts no storage domain. */
847
+ intakeTable;
848
+ /** Whether the state is durable; the startup check refuses to run without it. */
849
+ intakePersistent = false;
850
+ /** The resolved token, cached so a token command runs at most once. */
851
+ resolvedToken;
852
+ /** Whether token resolution finished; a missing credential is a result, not a retry. */
853
+ tokenResolved = false;
854
+ /** The last invalid time zone already reported, so a warning is not repeated per call. */
855
+ warnedTimeZone = "";
318
856
  /**
319
857
  * @param ctx - Host context carrying the llm service.
320
858
  * @param config - Validated deployment configuration.
321
859
  */
322
860
  constructor(ctx, config) {
323
861
  super(ctx, "githubIssue");
324
- this.repoUrl = config.repoUrl;
862
+ this.config = config;
325
863
  this.maxPrefillUrlLength = config.maxPrefillUrlLength;
326
864
  this.prefillTruncationNote = config.prefillTruncationNote;
327
865
  this.provider = config.provider;
328
866
  this.model = config.model;
867
+ this.tokenEnv = config.tokenEnv;
868
+ this.token = config.token;
869
+ this.tokenCommand = config.tokenCommand;
870
+ this.tokenCommandTimeoutMs = config.tokenCommandTimeoutMs;
871
+ this.apiBaseUrl = config.apiBaseUrl;
872
+ this.requestTimeoutMs = config.requestTimeoutMs;
873
+ }
874
+ /**
875
+ * The IANA zone the intake day is measured in.
876
+ *
877
+ * Read per call: a settings surface can rewrite it while the service runs. An
878
+ * invalid zone must not break the startup check or a dispatch, so it falls
879
+ * back to the host zone and is reported once per changed value.
880
+ * @returns the configured zone, or an empty string for the host zone.
881
+ */
882
+ intakeTimeZone() {
883
+ const zone = this.config.autopilotTimeZone.get();
884
+ if (zone.length === 0 || isValidTimeZone(zone)) return zone;
885
+ if (this.warnedTimeZone !== zone) {
886
+ this.warnedTimeZone = zone;
887
+ this.warn("autopilotTimeZone \"" + zone + "\" is not an IANA time zone; measuring the day in the host zone");
888
+ }
889
+ return "";
890
+ }
891
+ /**
892
+ * Open the durable intake state and, when enabled, run the first-startup
893
+ * check.
894
+ *
895
+ * The check is deliberately not awaited: opening a Session is useful work
896
+ * but not a prerequisite for the service, and a slow or unreachable GitHub
897
+ * must not hold up the host's boot. A failure is logged and retried on the
898
+ * next startup, because the day key is only committed after a completed pass.
899
+ */
900
+ async [Service.init]() {
901
+ await this.openIntakeState();
902
+ if (!this.config.autopilotEnabled.get()) return;
903
+ if (!this.intakePersistent) {
904
+ this.warn("autopilot is enabled but no durable intake state is available; skipping the startup check so it cannot dispatch the same issue twice");
905
+ return;
906
+ }
907
+ this.startupIntake();
329
908
  }
330
909
  /**
331
910
  * Resolve the model route for one model call.
@@ -389,7 +968,7 @@ let GithubIssueService = (() => {
389
968
  * @returns the prefill URL or an invalid-URL failure.
390
969
  */
391
970
  prefilledIssueUrl(request) {
392
- const repoUrl = request.repoUrl.length > 0 ? request.repoUrl : this.repoUrl;
971
+ const repoUrl = request.repoUrl.length > 0 ? request.repoUrl : this.config.repoUrl.get();
393
972
  if (!/^https:\/\/github\.com\/[^/]+\/[^/]+$/u.test(repoUrl)) return {
394
973
  ok: false,
395
974
  error: {
@@ -432,6 +1011,346 @@ let GithubIssueService = (() => {
432
1011
  };
433
1012
  }
434
1013
  /**
1014
+ * File one issue in the configured repository.
1015
+ *
1016
+ * The body is filed without its leading title heading: the optimizer repeats
1017
+ * the title there so one text can drive both the pre-filled URL form and a
1018
+ * direct filing, and GitHub takes the title as its own field.
1019
+ *
1020
+ * @param request - repository override and the report to file.
1021
+ * @returns the created issue, or a stable failure.
1022
+ */
1023
+ async createIssue(request) {
1024
+ const report = request.report;
1025
+ if (typeof report?.title !== "string" || typeof report.body !== "string") return INVALID_ISSUE_REQUEST;
1026
+ const title = report.title.trim();
1027
+ if (title.length === 0) return INVALID_ISSUE_REQUEST;
1028
+ const labels = [...report.labels ?? []].map((label) => label.trim()).filter((label) => label.length > 0);
1029
+ const normalized = {
1030
+ title,
1031
+ body: issueBody(report),
1032
+ labels
1033
+ };
1034
+ const result = await (await this.createClient(request.repoUrl)).createIssue(normalized);
1035
+ if (!result.ok) {
1036
+ this.trackError("createIssue", {
1037
+ code: result.error.code,
1038
+ message: result.error.message,
1039
+ featureCodeRef: this.featureCode("createIssue")
1040
+ });
1041
+ return result;
1042
+ }
1043
+ this.track("createIssue", "success", { issue: result.value.number });
1044
+ return result;
1045
+ }
1046
+ /**
1047
+ * List the open issues that carry the claim label.
1048
+ * @param request - repository, label, and count overrides.
1049
+ * @returns the claimable issues, or a stable failure.
1050
+ */
1051
+ async listClaimableIssues(request = {}) {
1052
+ const claimLabel = firstNonBlank(request.claimLabel, this.config.claimLabel.get()) ?? this.config.claimLabel.get();
1053
+ const max = clampCount(request.max ?? this.config.maxIssuesPerRun.get());
1054
+ const result = await (await this.createClient(request.repoUrl)).listClaimable(claimLabel, max);
1055
+ if (!result.ok) this.trackError("listClaimableIssues", {
1056
+ code: result.error.code,
1057
+ message: result.error.message,
1058
+ featureCodeRef: this.featureCode("listClaimableIssues")
1059
+ });
1060
+ return result;
1061
+ }
1062
+ /**
1063
+ * Hand one issue to a fresh Session.
1064
+ * @param request - the issue and optional Session placement.
1065
+ * @returns the created Session, or a stable failure.
1066
+ */
1067
+ async dispatchIssue(request) {
1068
+ return this.dispatch(request);
1069
+ }
1070
+ /**
1071
+ * Run one intake pass: list the claimable issues, skip the ones an earlier
1072
+ * pass already dispatched, and hand the rest to a fresh Session.
1073
+ *
1074
+ * The local day is committed only after the listing succeeded, so a pass that
1075
+ * could not reach GitHub is retried on the next startup instead of being
1076
+ * remembered as done. An issue that was already dispatched is never
1077
+ * dispatched again, even on a later day: its Session exists, and a second one
1078
+ * would duplicate the work.
1079
+ *
1080
+ * @param request - repository, label, count, and Session placement overrides.
1081
+ * @returns what the pass read and did.
1082
+ */
1083
+ async runIntake(request = {}) {
1084
+ const repoUrl = firstNonBlank(request.repoUrl, this.config.repoUrl.get()) ?? this.config.repoUrl.get();
1085
+ const claimLabel = firstNonBlank(request.claimLabel, this.config.claimLabel.get()) ?? this.config.claimLabel.get();
1086
+ const max = clampCount(request.max ?? this.config.maxIssuesPerRun.get());
1087
+ const listed = await this.listClaimableIssues({
1088
+ repoUrl,
1089
+ claimLabel,
1090
+ max
1091
+ });
1092
+ if (!listed.ok) return listed;
1093
+ const claimed = new Set(this.intakeState.dispatched.map((row) => row.number));
1094
+ const skipped = [];
1095
+ const fresh = [];
1096
+ for (const issue of listed.value) if (claimed.has(issue.number)) skipped.push(issue.number);
1097
+ else fresh.push(issue);
1098
+ const selected = fresh.slice(0, max);
1099
+ const dispatched = [];
1100
+ const failures = [];
1101
+ for (const issue of selected) {
1102
+ const result = await this.dispatch({
1103
+ issue,
1104
+ ...request.cwd === void 0 ? {} : { cwd: request.cwd },
1105
+ ...request.agentPreset === void 0 ? {} : { agentPreset: request.agentPreset }
1106
+ });
1107
+ if (result.ok) {
1108
+ dispatched.push({
1109
+ number: issue.number,
1110
+ sessionId: result.value.sessionId
1111
+ });
1112
+ continue;
1113
+ }
1114
+ failures.push({
1115
+ number: issue.number,
1116
+ code: result.error.code,
1117
+ message: result.error.message
1118
+ });
1119
+ }
1120
+ const checkedAt = Date.now();
1121
+ await this.commitIntakeState(localDayKey(checkedAt, this.intakeTimeZone()), dispatched);
1122
+ return {
1123
+ ok: true,
1124
+ value: Object.freeze({
1125
+ checkedAt,
1126
+ repoUrl,
1127
+ claimable: listed.value,
1128
+ dispatched: Object.freeze(dispatched),
1129
+ skipped: Object.freeze(skipped),
1130
+ failures: Object.freeze(failures)
1131
+ })
1132
+ };
1133
+ }
1134
+ /**
1135
+ * Create one Session, queue the issue prompt, and name the Session.
1136
+ *
1137
+ * The Session is created through the Session controller rather than the Agent
1138
+ * registry directly: the controller applies the deployment's preset and
1139
+ * workspace policy, so the dispatched Session behaves like one the user
1140
+ * opened from the Web. The prompt is queued with the Agent's followup, which
1141
+ * wakes the driver without waiting for a turn to be open elsewhere.
1142
+ *
1143
+ * @param request - the issue and optional Session placement.
1144
+ * @returns the created Session, or a stable failure.
1145
+ */
1146
+ async dispatch(request) {
1147
+ const controller = optionalService(this.ctx, "sessionController");
1148
+ const agents = optionalService(this.ctx, "agents");
1149
+ if (controller === void 0 || agents === void 0) return SESSION_UNAVAILABLE;
1150
+ const cwd = firstNonBlank(request.cwd, this.config.autopilotCwd.get());
1151
+ const agentPreset = firstNonBlank(request.agentPreset, this.config.autopilotPreset.get());
1152
+ let sessionId;
1153
+ try {
1154
+ sessionId = (await controller.create({
1155
+ ...cwd === void 0 ? {} : { cwd },
1156
+ ...agentPreset === void 0 ? {} : { agentPreset }
1157
+ })).sessionId;
1158
+ } catch (error) {
1159
+ return {
1160
+ ok: false,
1161
+ error: {
1162
+ code: "dispatch-failed",
1163
+ message: "the Session could not be created: " + describeError(error)
1164
+ }
1165
+ };
1166
+ }
1167
+ const handle = agents.get(sessionId);
1168
+ if (handle === void 0) return {
1169
+ ok: false,
1170
+ error: {
1171
+ code: "dispatch-failed",
1172
+ message: "Session \"" + sessionId + "\" was created but no live Agent was published for it"
1173
+ }
1174
+ };
1175
+ try {
1176
+ handle.agent.followup({
1177
+ content: [{
1178
+ type: "text",
1179
+ text: renderDispatchPrompt(this.config.autopilotPrompt.get(), request.issue)
1180
+ }],
1181
+ source: { kind: "github-issue" }
1182
+ });
1183
+ } catch (error) {
1184
+ return {
1185
+ ok: false,
1186
+ error: {
1187
+ code: "dispatch-failed",
1188
+ message: "Session \"" + sessionId + "\" was created but the issue prompt was refused: " + describeError(error)
1189
+ }
1190
+ };
1191
+ }
1192
+ this.renameSession(controller, sessionId, "Issue #" + request.issue.number + ": " + request.issue.title);
1193
+ this.track("dispatchIssue", "success", {
1194
+ issue: request.issue.number,
1195
+ sessionId
1196
+ });
1197
+ return {
1198
+ ok: true,
1199
+ value: Object.freeze({ sessionId })
1200
+ };
1201
+ }
1202
+ /**
1203
+ * Name a dispatched Session so the user can recognise it in the sidebar.
1204
+ *
1205
+ * A title is presentation, not part of the dispatch contract: a deployment
1206
+ * that mounts no title service still gets a working Session, so a failure
1207
+ * here is logged and never fails the dispatch.
1208
+ *
1209
+ * @param controller - the Session controller.
1210
+ * @param sessionId - the Session to name.
1211
+ * @param title - the proposed title.
1212
+ */
1213
+ async renameSession(controller, sessionId, title) {
1214
+ try {
1215
+ await controller.rename?.({
1216
+ sessionId,
1217
+ title: title.slice(0, 120)
1218
+ });
1219
+ } catch (error) {
1220
+ this.warn("Session \"" + sessionId + "\" was created without a title: " + describeError(error));
1221
+ }
1222
+ }
1223
+ /**
1224
+ * Run the first-startup check once per local day.
1225
+ *
1226
+ * The day is read from durable state, so a restart later the same day does
1227
+ * nothing; the check is skipped until the local calendar moves on.
1228
+ */
1229
+ async startupIntake() {
1230
+ try {
1231
+ const today = localDayKey(Date.now(), this.intakeTimeZone());
1232
+ if (this.intakeState.lastCheckedDay === today) return;
1233
+ const result = await this.runIntake({});
1234
+ if (!result.ok) {
1235
+ this.warn("issue intake did not complete: " + result.error.code + ": " + result.error.message);
1236
+ return;
1237
+ }
1238
+ this.track("startupIntake", "success", { dispatched: result.value.dispatched.length });
1239
+ } catch (error) {
1240
+ this.warn("issue intake failed: " + describeError(error));
1241
+ }
1242
+ }
1243
+ /**
1244
+ * Open the durable intake state when this deployment mounts storage.
1245
+ *
1246
+ * Storage is optional so the service still installs on its own bundle; the
1247
+ * startup check then stays off rather than running without a memory of what
1248
+ * it already dispatched.
1249
+ */
1250
+ async openIntakeState() {
1251
+ const storage = optionalService(this.ctx, "storageDomain");
1252
+ if (storage === void 0) return;
1253
+ try {
1254
+ const domain = await storage.open(await createIntakeDomain());
1255
+ this.ctx.effect(() => async () => {
1256
+ await domain.close();
1257
+ }, "github-issue.intakeStateClose");
1258
+ const table = domain.table("state");
1259
+ this.intakeTable = table;
1260
+ this.intakeState = table.get("intake") ?? EMPTY_INTAKE_STATE;
1261
+ this.intakePersistent = true;
1262
+ } catch (error) {
1263
+ this.warn("intake state is unavailable: " + describeError(error));
1264
+ }
1265
+ }
1266
+ /**
1267
+ * Commit one completed pass to durable state.
1268
+ *
1269
+ * The in-memory state is updated first so the next pass in this process sees
1270
+ * the dispatch even when the durable write fails; the warning then names the
1271
+ * consequence instead of pretending the write succeeded.
1272
+ *
1273
+ * @param day - local day the pass completed.
1274
+ * @param dispatched - issues handed to a Session in this pass.
1275
+ */
1276
+ async commitIntakeState(day, dispatched) {
1277
+ const appended = dispatched.map((entry) => ({
1278
+ number: entry.number,
1279
+ day,
1280
+ sessionId: entry.sessionId
1281
+ }));
1282
+ const next = Object.freeze({
1283
+ lastCheckedDay: day,
1284
+ dispatched: Object.freeze([...this.intakeState.dispatched, ...appended].slice(-500))
1285
+ });
1286
+ this.intakeState = next;
1287
+ const table = this.intakeTable;
1288
+ if (table === void 0) return;
1289
+ try {
1290
+ await table.put(INTAKE_STATE_KEY, next);
1291
+ } catch (error) {
1292
+ this.warn("intake state could not be saved, so a later pass may dispatch the same issue again: " + describeError(error));
1293
+ }
1294
+ }
1295
+ /**
1296
+ * Build one GitHub client for a call.
1297
+ * @param repoUrl - repository override, or the configured repository.
1298
+ * @returns the client, carrying the resolved credential.
1299
+ */
1300
+ async createClient(repoUrl) {
1301
+ return new GithubIssueClient({
1302
+ repoUrl: firstNonBlank(repoUrl, this.config.repoUrl.get()) ?? this.config.repoUrl.get(),
1303
+ apiBaseUrl: this.apiBaseUrl,
1304
+ token: await this.resolveToken(),
1305
+ timeoutMs: this.requestTimeoutMs
1306
+ });
1307
+ }
1308
+ /**
1309
+ * Resolve the GitHub credential once.
1310
+ *
1311
+ * The order is explicit config, then the configured environment variable,
1312
+ * then the configured command. The command runs at most once per process so
1313
+ * a keyring-backed credential is not fetched on every request, and its output
1314
+ * is never logged.
1315
+ *
1316
+ * @returns the token, or undefined when the deployment has none.
1317
+ */
1318
+ async resolveToken() {
1319
+ if (this.tokenResolved) return this.resolvedToken;
1320
+ this.tokenResolved = true;
1321
+ const direct = firstNonBlank(this.token);
1322
+ if (direct !== void 0) return this.resolvedToken = direct;
1323
+ const fromEnv = this.tokenEnv.length === 0 ? void 0 : firstNonBlank(process.env[this.tokenEnv]);
1324
+ if (fromEnv !== void 0) return this.resolvedToken = fromEnv;
1325
+ if (this.tokenCommand.trim().length === 0) return void 0;
1326
+ try {
1327
+ const { stdout } = await execAsync(this.tokenCommand, {
1328
+ timeout: this.tokenCommandTimeoutMs,
1329
+ windowsHide: true,
1330
+ maxBuffer: 65536
1331
+ });
1332
+ const fromCommand = firstNonBlank(stdout);
1333
+ if (fromCommand !== void 0) return this.resolvedToken = fromCommand;
1334
+ } catch (error) {
1335
+ this.warn("token command failed: " + describeError(error));
1336
+ }
1337
+ }
1338
+ /**
1339
+ * The telemetry feature anchor for one intake action.
1340
+ * @param action - the method the event belongs to.
1341
+ * @returns the stable feature-code reference.
1342
+ */
1343
+ featureCode(action) {
1344
+ return "github-issue:" + action;
1345
+ }
1346
+ /**
1347
+ * Log one non-fatal intake problem.
1348
+ * @param message - what went wrong and what it costs.
1349
+ */
1350
+ warn(message) {
1351
+ this.ctx.logger?.warn("github-issue: " + message);
1352
+ }
1353
+ /**
435
1354
  * Report one model failure and return the business failure.
436
1355
  *
437
1356
  * The preserved DSH code goes into both the message and the telemetry event:
@@ -623,5 +1542,38 @@ function buildPrefillUrl(repoUrl, report, maxLength, note) {
623
1542
  }
624
1543
  return candidate.length <= maxLength ? candidate : compose(note);
625
1544
  }
1545
+ /**
1546
+ * Read one optional host service by its Cordis key.
1547
+ *
1548
+ * The intake loop works with services this package does not depend on: a
1549
+ * deployment may mount no storage, no Session controller, or no Agent registry.
1550
+ * Reading them by key keeps those features optional without turning the plugin
1551
+ * into a hard dependency of the whole Web composition.
1552
+ *
1553
+ * @param ctx - the host context.
1554
+ * @param name - the service key.
1555
+ * @returns the service, or undefined when this deployment mounts none.
1556
+ */
1557
+ function optionalService(ctx, name) {
1558
+ return ctx.get.call(ctx, name);
1559
+ }
1560
+ /**
1561
+ * Bound one requested issue count to the accepted range.
1562
+ * @param value - the requested count.
1563
+ * @returns an integer from 1 through 100.
1564
+ */
1565
+ function clampCount(value) {
1566
+ if (!Number.isFinite(value)) return 1;
1567
+ return Math.min(100, Math.max(1, Math.trunc(value)));
1568
+ }
1569
+ /**
1570
+ * Render an unknown thrown value as a message.
1571
+ * @param error - the thrown value.
1572
+ * @returns a non-empty message.
1573
+ */
1574
+ function describeError(error) {
1575
+ if (error instanceof Error) return error.message;
1576
+ return String(error);
1577
+ }
626
1578
  //#endregion
627
1579
  export { Config, GithubIssueService, GithubIssueService as default };