@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/LICENSE +21 -0
- package/README.i18n.yaml +2 -2
- package/README.md +58 -3
- package/README.zh.md +58 -3
- package/cordis.patch.yml +12 -1
- package/lib/client.d.ts +2 -2
- package/lib/index.d.ts +231 -6
- package/lib/index.js +958 -6
- package/lib/types.d.ts +131 -1
- package/package.json +77 -70
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
|
-
|
|
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.
|
|
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 };
|