@reserendipity/dsh-superagent 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +50 -0
  3. package/README.md +206 -2
  4. package/cordis.patch.yml +214 -0
  5. package/examples/profile.cordis.patch.yml +31 -0
  6. package/lib/client.js +7695 -0
  7. package/lib/feishu-Dvj-1eJf.js +1325 -0
  8. package/lib/feishu-watch.d.ts +125 -0
  9. package/lib/feishu-watch.js +2 -0
  10. package/lib/github-pull-DRiuuSs8.js +597 -0
  11. package/lib/github-watch.d.ts +2 -0
  12. package/lib/github-watch.js +836 -0
  13. package/lib/host-BIWKSB5j.js +124 -0
  14. package/lib/inbox-watch.d.ts +43 -0
  15. package/lib/inbox-watch.js +343 -0
  16. package/lib/index.d.ts +369 -0
  17. package/lib/index.js +13654 -0
  18. package/lib/instance-status-Cqaju-dw.js +87 -0
  19. package/lib/instances-view.d.ts +164 -0
  20. package/lib/instances-view.js +258 -0
  21. package/lib/intake-TNIkqilh.d.ts +735 -0
  22. package/lib/kind--8woUEex.d.ts +1019 -0
  23. package/lib/poll-Bm5EYA2c.js +819 -0
  24. package/lib/schemas-MhNERx8d.js +4484 -0
  25. package/lib/schemas-lx-F2nnt.d.ts +2134 -0
  26. package/locale/en.json +6 -0
  27. package/locale/zh.json +6 -0
  28. package/package.json +159 -4
  29. package/prompts/admission.md +68 -0
  30. package/prompts/cli.md +17 -0
  31. package/prompts/dispatch.md +59 -0
  32. package/prompts/handoff-orchestrator.md +4 -0
  33. package/prompts/handoff-worker.md +7 -0
  34. package/prompts/handoff.md +6 -0
  35. package/prompts/orchestrator.md +16 -0
  36. package/prompts/presets/brief/communication.md +4 -0
  37. package/prompts/presets/brief/delivery.md +3 -0
  38. package/prompts/presets/brief/scope.md +4 -0
  39. package/prompts/presets/evidence/communication.md +6 -0
  40. package/prompts/presets/evidence/delivery.md +4 -0
  41. package/prompts/presets/evidence/scope.md +5 -0
  42. package/prompts/presets/result/communication.md +4 -0
  43. package/prompts/presets/result/delivery.md +3 -0
  44. package/prompts/presets/result/scope.md +5 -0
  45. package/prompts/replies.md +35 -0
  46. package/prompts/shared.md +9 -0
  47. package/prompts/subscriptions.md +13 -0
  48. package/prompts/team/communication.md +101 -0
  49. package/prompts/team/delivery.md +35 -0
  50. package/prompts/team/scope.md +26 -0
  51. package/prompts/work.md +49 -0
  52. package/prompts/worker.md +10 -0
  53. package/schema/declarative-settings.example.json +26 -0
  54. package/schema/declarative-settings.schema.json +348 -0
  55. package/skills/README.md +14 -0
  56. package/skills/dsh-superagent-feishu/SKILL.md +12 -0
  57. package/skills/dsh-superagent-github/SKILL.md +14 -0
@@ -0,0 +1,836 @@
1
+ import { c as key, n as sourceContext, o as sameName, t as health } from "./host-BIWKSB5j.js";
2
+ import { E as CliError, O as validItems, i as pollThreads$1, l as GithubAccountsSchema, n as cursorAt, o as KV, r as pause, t as POLL_HORIZON_MS } from "./poll-Bm5EYA2c.js";
3
+ import { c as object, i as boolean, o as looseObject, p as unknown, r as array, s as number, u as string } from "./schemas-MhNERx8d.js";
4
+ import { C as issueTrigger, S as githubPage, _ as SOURCE, a as pullsOf, b as commentTrigger, c as ciFact, d as githubThreadContext, f as pullChecks, g as IssueSchema, h as CommentSchema$1, l as ciFailures, n as observePull, o as readPull, s as ciCounts, u as ciSummary, v as branchCiTrigger, w as pullTrigger, x as githubAddress, y as ciTrigger } from "./github-pull-DRiuuSs8.js";
5
+ //#region src/sources/github-channel.ts
6
+ /** GitHub outbound channel: posts issue/PR comments and review thread replies under the agent account. */
7
+ const CommentSchema = looseObject({
8
+ id: number(),
9
+ body: string().nullish(),
10
+ in_reply_to_id: number().nullish(),
11
+ user: looseObject({ login: string() }).nullish()
12
+ });
13
+ /** True when an issue is permanently deleted (410) or locked (403); 404 is retried because it can indicate temporary permission loss on a private repo. */
14
+ const gone = (error) => error instanceof CliError && (error.code === 410 || error.code === 403 && /locked/i.test(error.message));
15
+ const missing = (error) => error instanceof CliError && (error.code === 404 || error.code === 410);
16
+ /** Posts a comment or review-thread reply with an embedded HTML marker so retries do not create duplicate comments. */
17
+ async function sendGithub(host, message) {
18
+ const { commands } = host;
19
+ const { repository, github } = commands.deployment;
20
+ const number = Number(message.channel.address.number);
21
+ if (!number) throw new Error("Missing GitHub reply target");
22
+ try {
23
+ const address = githubAddress(message.channel.address.url ?? "", repository);
24
+ let rootId;
25
+ if (address?.pr && address.discussion && address.number === number) {
26
+ const source = await commands.api(CommentSchema, `pulls/comments/${address.discussion}`).catch((error) => {
27
+ if (missing(error)) return void 0;
28
+ throw error;
29
+ });
30
+ rootId = source ? source.in_reply_to_id ?? source.id : void 0;
31
+ }
32
+ const path = rootId === void 0 ? `issues/${number}/comments` : `pulls/${number}/comments`;
33
+ const marker = `<!-- dsh-superagent:${message.id} -->`;
34
+ const mine = (comment) => sameName(comment.user?.login ?? "", github.login) && (rootId === void 0 || comment.in_reply_to_id === rootId) && (comment.body ?? "").includes(marker);
35
+ if ((await commands.pages(CommentSchema, path)).some(mine)) return "sent";
36
+ const target = rootId === void 0 ? path : `${path}/${rootId}/replies`;
37
+ await commands.api(unknown(), target, "--method", "POST", "-f", `body=${message.text}\n\n${marker}`);
38
+ return "sent";
39
+ } catch (error) {
40
+ if (gone(error)) return "gone";
41
+ throw error;
42
+ }
43
+ }
44
+ function githubLink(address, links) {
45
+ const number = address.number ?? "";
46
+ const review = !!links.repository && !!githubAddress(address.url ?? "", links.repository)?.discussion;
47
+ return {
48
+ url: address.url ?? (links.repository ? githubPage(links.repository, Number(number)) : ""),
49
+ label: review ? {
50
+ zh: `GitHub #${number} 的 review 讨论`,
51
+ en: `Review discussion on GitHub #${number}`
52
+ } : {
53
+ zh: `GitHub #${number}`,
54
+ en: `GitHub #${number}`
55
+ }
56
+ };
57
+ }
58
+ //#endregion
59
+ //#region src/sources/github.ts
60
+ /** A notification of the agent account; `subject.url` is the API address of its Issue or PR. */
61
+ const NotificationSchema = looseObject({
62
+ repository: looseObject({ full_name: string() }),
63
+ subject: looseObject({ url: string().nullish() })
64
+ });
65
+ const GithubConfigSchema = GithubAccountsSchema.extend({
66
+ pollSeconds: number().int().min(60).max(3600).default(60),
67
+ failuresBeforeError: number().int().min(1).max(20).default(3),
68
+ observeIssues: boolean().default(false),
69
+ observePulls: boolean().default(false),
70
+ observeComments: boolean().default(false),
71
+ observeCi: boolean().default(false)
72
+ }).strict();
73
+ const OBSERVED = {
74
+ issues: "observeIssues",
75
+ pulls: "observePulls",
76
+ comments: "observeComments",
77
+ ci: "observeCi"
78
+ };
79
+ function scopeOf(meta) {
80
+ if (meta.ci === true) return typeof meta.branch === "string" ? "ci" : void 0;
81
+ if (meta.kind === "issue") return "issues";
82
+ if (meta.kind === "pull") return "pulls";
83
+ return meta.kind === "conversation" || meta.kind === "inline" || meta.kind === "review" ? "comments" : void 0;
84
+ }
85
+ const githubKind = {
86
+ kind: SOURCE,
87
+ title: {
88
+ zh: "GitHub",
89
+ en: "GitHub"
90
+ },
91
+ description: {
92
+ zh: "智能体的 GitHub 账号;接收仓库里发给它的通知、已订阅 Issue 与 PR 的讨论、CI 状态变化和 PR 合并关闭。",
93
+ en: "The agent's GitHub accounts; receives notifications to it in the repository, discussion on subscribed Issues and PRs, CI changes, and PR merge or close."
94
+ },
95
+ schema: GithubConfigSchema,
96
+ fields: [
97
+ {
98
+ key: "repository",
99
+ type: "text",
100
+ pattern: "[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+",
101
+ label: {
102
+ zh: "监听的仓库",
103
+ en: "Repository"
104
+ },
105
+ hint: {
106
+ zh: "写作 owner/name。这个来源接收这个仓库的通知和讨论;保存时检查智能体账号能否推送到它,然后重启 dsh-superagent。",
107
+ en: "Written owner/name. This Source receives the repository's notifications and discussion; saving checks that the agent account can push to it, then restarts dsh-superagent."
108
+ }
109
+ },
110
+ {
111
+ key: "cli",
112
+ type: "text",
113
+ label: {
114
+ zh: "智能体的 GitHub CLI",
115
+ en: "Agent GitHub CLI"
116
+ },
117
+ hint: {
118
+ zh: "命令名或绝对路径,通常是用 GH_CONFIG_DIR 登录智能体账号的 gh 包装脚本。智能体以它登录的账号行事,执行器以 gh 调用它。保存时检查它登录的账号能否推送到仓库,然后重启 dsh-superagent。",
119
+ en: "A command name or absolute path, usually a gh wrapper signed in to the agent account through GH_CONFIG_DIR. The agent acts as the account it is signed in to, and the Executor calls it as gh. Saving checks that this account can push to the repository, then restarts dsh-superagent."
120
+ }
121
+ },
122
+ {
123
+ key: "pollSeconds",
124
+ type: "number",
125
+ advanced: true,
126
+ min: 60,
127
+ max: 3600,
128
+ label: {
129
+ zh: "轮询间隔 / 秒",
130
+ en: "Polling interval / seconds"
131
+ },
132
+ hint: {
133
+ zh: "GitHub 要求更长间隔时以 GitHub 为准。",
134
+ en: "A longer interval requested by GitHub wins."
135
+ }
136
+ },
137
+ {
138
+ key: "failuresBeforeError",
139
+ type: "number",
140
+ advanced: true,
141
+ min: 1,
142
+ max: 20,
143
+ label: {
144
+ zh: "连续失败几次后报告异常",
145
+ en: "Failed polls before reporting an error"
146
+ },
147
+ hint: {
148
+ zh: "单次网络中断会在下一次轮询时自动重试,不必报告。",
149
+ en: "A single network interruption is retried on the next poll and need not be reported."
150
+ }
151
+ },
152
+ {
153
+ key: "observeIssues",
154
+ type: "switch",
155
+ label: {
156
+ zh: "接收仓库里的新 Issue",
157
+ en: "Receive new Issues in the repository"
158
+ },
159
+ hint: {
160
+ zh: "打开后,没有 @ 智能体的新 Issue 也会收到,由策略的接收表决定是否成为候选。从打开的时候开始,不读以前的。",
161
+ en: "New Issues that do not mention the agent are received too, and the Policy's admission table decides whether they become candidates. Starts when switched on; earlier ones are not read."
162
+ }
163
+ },
164
+ {
165
+ key: "observePulls",
166
+ type: "switch",
167
+ label: {
168
+ zh: "接收仓库里的新 PR",
169
+ en: "Receive new pull requests in the repository"
170
+ },
171
+ hint: {
172
+ zh: "打开后,没有 @ 智能体的新 PR 也会收到,由策略的接收表决定是否成为候选。",
173
+ en: "New pull requests that do not mention the agent are received too, and the Policy's admission table decides whether they become candidates."
174
+ }
175
+ },
176
+ {
177
+ key: "observeComments",
178
+ type: "switch",
179
+ label: {
180
+ zh: "接收仓库里的评论",
181
+ en: "Receive comments in the repository"
182
+ },
183
+ hint: {
184
+ zh: "打开后,Issue 和 PR 上没有 @ 智能体的评论和代码评论也会收到,由策略的接收表决定是否成为候选。",
185
+ en: "Comments and review comments on Issues and PRs that do not mention the agent are received too, and the Policy's admission table decides whether they become candidates."
186
+ }
187
+ },
188
+ {
189
+ key: "observeCi",
190
+ type: "switch",
191
+ label: {
192
+ zh: "接收默认分支的 CI",
193
+ en: "Receive the default branch's CI"
194
+ },
195
+ hint: {
196
+ zh: "打开后,默认分支的 CI 失败和恢复会收到,由策略的接收表决定是否成为候选。",
197
+ en: "Failures of the default branch's CI, and its recovery, are received, and the Policy's admission table decides whether they become candidates."
198
+ }
199
+ }
200
+ ],
201
+ scope: ({ meta }, config) => {
202
+ const row = scopeOf(meta);
203
+ return {
204
+ listened: true,
205
+ observed: row !== void 0 && config[OBSERVED[row]],
206
+ ...row ? { row } : {}
207
+ };
208
+ },
209
+ health: ["github"],
210
+ channel: {
211
+ send: sendGithub,
212
+ link: githubLink
213
+ },
214
+ threadContext: githubThreadContext,
215
+ find: (query) => {
216
+ const number = (/^#?(\d+)$/.exec(query.trim()) ?? /github\.com\/[^/]+\/[^/]+\/(?:pull|issues)\/(\d+)/.exec(query))?.[1];
217
+ return {
218
+ threads: number ? [`${SOURCE}:${number}`] : [],
219
+ terms: []
220
+ };
221
+ },
222
+ policy: {
223
+ schema: object({
224
+ allowed: array(object({ id: string().regex(/^[A-Za-z0-9-]+$/) }).strict()).default([]),
225
+ bots: array(object({ id: string().regex(/^[A-Za-z0-9-]+(\[bot\])?$/) }).strict()).optional()
226
+ }).strict(),
227
+ fields: [{
228
+ key: "allowed",
229
+ type: "rows",
230
+ label: {
231
+ zh: "能发起工作的 GitHub 账号",
232
+ en: "GitHub accounts that may originate work"
233
+ },
234
+ columns: [{
235
+ key: "id",
236
+ type: "text",
237
+ label: {
238
+ zh: "账号",
239
+ en: "Account"
240
+ },
241
+ pattern: "[A-Za-z0-9\\-]+"
242
+ }]
243
+ }, {
244
+ key: "bots",
245
+ type: "rows",
246
+ label: {
247
+ zh: "评论可以成为候选的机器人",
248
+ en: "Bots whose comments may become candidates"
249
+ },
250
+ hint: {
251
+ zh: "名单里的机器人账号(例如 dependabot[bot])的评论在来源收到时成为推断的候选,交给评估:评论 @ 了智能体,或者打开了“接收仓库里的评论”。它们不能发起明确的请求,也不能停止工单;其他机器人的评论不接收。",
252
+ en: "A comment of a bot account on this list, such as dependabot[bot], becomes an inferred candidate for an assessment when the Source receives it: when it mentions the agent, or with Receive comments in the repository on. Such a comment never makes an explicit request or stops a ticket; other bots' comments are not admitted."
253
+ },
254
+ columns: [{
255
+ key: "id",
256
+ type: "text",
257
+ label: {
258
+ zh: "账号",
259
+ en: "Account"
260
+ },
261
+ pattern: "[A-Za-z0-9\\-]+(\\[bot\\])?"
262
+ }]
263
+ }],
264
+ explicit: {
265
+ zh: "名单里的账号在 Issue 或评论里 @ 智能体的账号。",
266
+ en: "An account on the list mentions the agent's account in an Issue or a comment."
267
+ },
268
+ rows(stored) {
269
+ const config = GithubConfigSchema.partial().parse(stored);
270
+ const note = (observed) => observed ? {
271
+ zh: "全部",
272
+ en: "All"
273
+ } : {
274
+ zh: "仅 @",
275
+ en: "@ only"
276
+ };
277
+ const row = (id, label) => ({
278
+ id,
279
+ label,
280
+ note: note(config[OBSERVED[id]]),
281
+ cells: {
282
+ listedUnaddressed: !!config[OBSERVED[id]],
283
+ unlistedAddressed: true,
284
+ unlistedUnaddressed: !!config[OBSERVED[id]]
285
+ }
286
+ });
287
+ return [
288
+ row("issues", {
289
+ zh: "新 Issue",
290
+ en: "New Issues"
291
+ }),
292
+ row("pulls", {
293
+ zh: "新 PR",
294
+ en: "New pull requests"
295
+ }),
296
+ row("comments", {
297
+ zh: "评论",
298
+ en: "Comments"
299
+ }),
300
+ {
301
+ id: "ci",
302
+ label: {
303
+ zh: "默认分支的 CI",
304
+ en: "Default branch CI"
305
+ },
306
+ note: config.observeCi ? {
307
+ zh: "接收",
308
+ en: "Received"
309
+ } : {
310
+ zh: "不接收",
311
+ en: "Not received"
312
+ },
313
+ cells: {
314
+ listedUnaddressed: false,
315
+ unlistedAddressed: false,
316
+ unlistedUnaddressed: !!config.observeCi
317
+ }
318
+ }
319
+ ];
320
+ }
321
+ },
322
+ environment: {
323
+ variables: [
324
+ {
325
+ key: "DSH_SUPERAGENT_GITHUB_REPOSITORY",
326
+ description: {
327
+ zh: "这个来源监听的仓库,owner/name。智能体在这里开的 PR,分支最后一段是 DSH_SUPERAGENT_NAME 时(例如 agent/$DSH_SUPERAGENT_NAME)会关联到工单",
328
+ en: "The repository this Source watches, as owner/name. A PR the agent opens here from a branch whose last path segment is DSH_SUPERAGENT_NAME (for example agent/$DSH_SUPERAGENT_NAME) binds to the ticket"
329
+ }
330
+ },
331
+ {
332
+ key: "DSH_SUPERAGENT_GITHUB_ACCOUNT",
333
+ description: {
334
+ zh: "智能体的 GitHub 账号;gh 以它登录,除自动合并外的 GitHub 操作都用 gh,不用其他账号或凭据",
335
+ en: "The agent's GitHub account. gh is signed in to it; use gh for every GitHub operation except auto-merge, and never another account or credential"
336
+ }
337
+ },
338
+ {
339
+ key: "DSH_SUPERAGENT_GITHUB_COMMIT_EMAIL",
340
+ description: {
341
+ zh: "智能体账号的提交邮箱。这里的 git 没有身份:按提示词写明的作者提交,没有写明时用智能体账号和这个邮箱",
342
+ en: "Commit email of the agent's account. git has no identity here: commit as the author these instructions name, or as the agent's account with this email when they name none"
343
+ }
344
+ },
345
+ {
346
+ key: "DSH_SUPERAGENT_GITHUB_NUMBER",
347
+ description: {
348
+ zh: "接下工单的 Issue 或 PR 编号;工单不是从 GitHub 来的时为空",
349
+ en: "Number of the Issue or PR that started the ticket; empty when the ticket did not start on GitHub"
350
+ }
351
+ },
352
+ {
353
+ key: "DSH_SUPERAGENT_GITHUB_PR_HEAD",
354
+ description: {
355
+ zh: "接下工单的 PR 的 head 引用 refs/pull/<编号>/head;不是 PR 时为空",
356
+ en: "Head ref refs/pull/<number>/head of the PR that started the ticket; empty when it was not a PR"
357
+ }
358
+ }
359
+ ],
360
+ values: ({ deployment, trigger }) => {
361
+ const number = trigger?.threads[0]?.source === "github" ? Number(trigger.meta.number) || 0 : 0;
362
+ return {
363
+ DSH_SUPERAGENT_GITHUB_REPOSITORY: deployment.repository,
364
+ DSH_SUPERAGENT_GITHUB_ACCOUNT: deployment.github.login,
365
+ DSH_SUPERAGENT_GITHUB_COMMIT_EMAIL: deployment.github.noreply,
366
+ DSH_SUPERAGENT_GITHUB_NUMBER: number ? String(number) : "",
367
+ DSH_SUPERAGENT_GITHUB_PR_HEAD: number && trigger?.meta.isPr === true ? `refs/pull/${number}/head` : ""
368
+ };
369
+ }
370
+ },
371
+ facts: (deployment) => deployment ? { cli: {
372
+ zh: `当前账号 @${deployment.github.login}`,
373
+ en: `Current account @${deployment.github.login}`
374
+ } } : {}
375
+ };
376
+ const name = "dsh-superagent-github";
377
+ const inject = ["orchestrator"];
378
+ function apply(ctx) {
379
+ ctx.effect(() => ctx.orchestrator.sources.register(githubKind), "github: source kind");
380
+ ctx.effect(() => {
381
+ const controller = new AbortController();
382
+ const running = ctx.orchestrator.sources.instances().filter((instance) => instance.kind === githubKind.kind && instance.installed).map((instance) => {
383
+ const source = sourceContext(ctx.orchestrator, githubKind, instance.id);
384
+ return watch(source, controller.signal).catch((error) => {
385
+ source.report("github", {
386
+ state: "error",
387
+ detail: String(error)
388
+ });
389
+ });
390
+ });
391
+ return async () => {
392
+ controller.abort();
393
+ await Promise.all(running);
394
+ };
395
+ });
396
+ }
397
+ /** Returns an error health once `failures` consecutive polls fail, or undefined so a single network interruption stays quiet. */
398
+ function pollFailure(failures, threshold, error) {
399
+ return failures >= threshold ? health("error", "pollFailures", {
400
+ count: String(failures),
401
+ error: String(error)
402
+ }) : void 0;
403
+ }
404
+ async function watch(ctx, signal) {
405
+ let interval = 60;
406
+ let failures = 0;
407
+ await ctx.ready;
408
+ while (!ctx.configured()) {
409
+ if (signal.aborted) return;
410
+ ctx.report("github", health("paused", "setupPending"));
411
+ await pause(1e3, signal);
412
+ }
413
+ while (!signal.aborted) {
414
+ const config = ctx.config(SOURCE);
415
+ try {
416
+ if (config) interval = await pollRound(ctx, signal);
417
+ else ctx.report("github", health("paused", "sourceOff"));
418
+ failures = 0;
419
+ } catch (error) {
420
+ failures += 1;
421
+ const failed = pollFailure(failures, config?.failuresBeforeError ?? 1, error);
422
+ if (!signal.aborted && failed) ctx.report("github", failed);
423
+ }
424
+ await pause(Math.max(interval, ctx.config("github")?.pollSeconds ?? 60) * 1e3, signal);
425
+ }
426
+ }
427
+ const newRound = (signal) => ({
428
+ started: (/* @__PURE__ */ new Date()).toISOString(),
429
+ signal,
430
+ pulls: /* @__PURE__ */ new Map(),
431
+ read: /* @__PURE__ */ new Set(),
432
+ failures: [],
433
+ dropped: [],
434
+ abandoned: [],
435
+ tally: { skipped: 0 }
436
+ });
437
+ const stopped = (ctx, round) => !!round.signal?.aborted || !ctx.config("github");
438
+ const RETRY_WINDOW_MS = 864e5;
439
+ /** True when GitHub returns HTTP 410 (Gone); HTTP 404 is retried because it can indicate revoked access to a private repository. */
440
+ const deleted = (error) => error instanceof CliError && error.code === 410;
441
+ /** Records a failed read on the Thread and in the round; deleted (410) or abandoned (24h+) threads do not fail the round. */
442
+ function failed(ctx, round, number, error, abandoned = false) {
443
+ if (deleted(error)) round.dropped.push(number);
444
+ else if (abandoned) round.abandoned.push(number);
445
+ else round.failures.push(`#${number}: ${String(error)}`);
446
+ ctx.threads.patch(`github:${number}`, { pollError: String(error) });
447
+ }
448
+ function included(raw) {
449
+ const split = raw.search(/\r?\n\r?\n/);
450
+ if (split < 0) throw new Error("GitHub returned no HTTP headers");
451
+ return {
452
+ headers: raw.slice(0, split),
453
+ body: raw.slice(split).trim()
454
+ };
455
+ }
456
+ const ListedSchema = CommentSchema$1.extend({
457
+ issue_url: string().nullish(),
458
+ pull_request_url: string().nullish()
459
+ });
460
+ /** Repository-wide event streams; GitHub's `/issues` endpoint includes PRs, and `/issues/comments` covers issue and PR conversation comments (excluding inline review comments). */
461
+ const STREAMS = [
462
+ {
463
+ id: "issues",
464
+ observed: (config) => config.observeIssues || config.observePulls,
465
+ path: "issues?state=all"
466
+ },
467
+ {
468
+ id: "comments",
469
+ observed: (config) => config.observeComments,
470
+ path: "issues/comments?"
471
+ },
472
+ {
473
+ id: "review-comments",
474
+ observed: (config) => config.observeComments,
475
+ path: "pulls/comments?"
476
+ }
477
+ ];
478
+ /** Polls repository-wide streams (new Issues/PRs, comments, default-branch CI); each stream reads from when it was switched on, resets when switched off, and fails independently. */
479
+ async function pollRepository(ctx, round) {
480
+ const config = ctx.config(SOURCE);
481
+ if (!config) return;
482
+ for (const stream of STREAMS) {
483
+ if (stopped(ctx, round)) return;
484
+ if (!stream.observed(config)) {
485
+ ctx.state.set(KV.github.stream(stream.id), void 0);
486
+ continue;
487
+ }
488
+ try {
489
+ await readStream(ctx, round, stream.id, stream.path, config);
490
+ } catch (error) {
491
+ if (!stopped(ctx, round)) round.failures.push(`${stream.id}: ${String(error)}`);
492
+ }
493
+ }
494
+ if (!config.observeCi) {
495
+ const seen = ctx.state.get(KV.github.branchCi);
496
+ ctx.state.set(KV.github.branchCi, seen && { revision: seen.revision });
497
+ return;
498
+ }
499
+ try {
500
+ await pollBranchCi(ctx, round);
501
+ } catch (error) {
502
+ if (!stopped(ctx, round)) round.failures.push(`default branch CI: ${String(error)}`);
503
+ }
504
+ }
505
+ /**
506
+ * Polls one repository-wide event stream (oldest first) since its cursor and steps the cursor to five seconds before the
507
+ * newest item (`cursorAt`), relying on Trigger ID deduplication so repeated messages are no-ops. While the cursor stays
508
+ * unchanged (including when clamped to the hourly horizon), sending the last page's ETag makes an unchanged list return
509
+ * HTTP 304 and cost no rate limit. Items from before their kind was switched on or beyond the horizon are ignored.
510
+ */
511
+ async function readStream(ctx, round, id, path, config) {
512
+ const name = KV.github.stream(id);
513
+ const stored = ctx.state.get(name);
514
+ const kinds = id === "issues" ? [...config.observeIssues ? ["issues"] : [], ...config.observePulls ? ["pulls"] : []] : ["comments"];
515
+ const before = typeof stored?.started === "object" ? stored.started : {};
516
+ const started = Object.fromEntries(kinds.map((kind) => [kind, before[kind] ?? round.started]));
517
+ const horizon = Math.floor((Date.parse(round.started) - POLL_HORIZON_MS) / 36e5) * 36e5;
518
+ const since = new Date(Math.max(Date.parse(stored?.since ?? round.started), horizon)).toISOString();
519
+ const conditional = stored?.etag && stored.since === since ? ["-H", `If-None-Match: ${stored.etag}`] : [];
520
+ let raw;
521
+ const listed = `${path}&sort=updated&direction=asc&per_page=100&since=${encodeURIComponent(since)}`;
522
+ try {
523
+ raw = await ctx.commands.withSignal(round.signal).api(listed, "--include", ...conditional);
524
+ } catch (error) {
525
+ if (error instanceof CliError && error.code === 304) return;
526
+ throw error;
527
+ }
528
+ const { headers, body } = included(raw);
529
+ const items = validItems(ListedSchema, array(unknown()).parse(JSON.parse(body)), round.tally);
530
+ const { login } = ctx.deployment.github;
531
+ const fresh = (at, kind) => kind in started && Date.parse(at ?? "") >= Math.max(Date.parse(started[kind]), horizon);
532
+ for (const item of items) {
533
+ if (stopped(ctx, round)) throw new Error("The GitHub Source stopped during the poll");
534
+ if (sameName(item.user.login, login)) continue;
535
+ if (id === "issues") {
536
+ const issue = IssueSchema.safeParse(item);
537
+ const pr = issue.success && !!issue.data.pull_request;
538
+ if (!issue.success || !fresh(issue.data.created_at, pr ? "pulls" : "issues")) continue;
539
+ await ctx.receive(pr ? pullTrigger(issue.data, login) : issueTrigger(issue.data, login));
540
+ nameThread(ctx, issue.data.number, issue.data.title);
541
+ continue;
542
+ }
543
+ const number = Number(/\/(?:issues|pulls)\/(\d+)$/.exec((id === "comments" ? item.issue_url : item.pull_request_url) ?? "")?.[1]);
544
+ if (!number || !fresh(item.updated_at ?? item.created_at, "comments")) continue;
545
+ const ref = {
546
+ number,
547
+ isPr: id === "review-comments" || /\/pull\//.test(item.html_url ?? ""),
548
+ ...item.html_url ? { url: item.html_url } : {}
549
+ };
550
+ const trigger = commentTrigger(item, ref, id === "comments" ? "conversation" : "inline", login);
551
+ if (trigger.text) await ctx.receive(trigger);
552
+ }
553
+ const newest = Math.max(...items.map((item) => Date.parse(item.updated_at ?? "")).filter(Number.isFinite));
554
+ const moved = Number.isFinite(newest) ? cursorAt(new Date(newest).toISOString()) : since;
555
+ const next = Date.parse(moved) > Date.parse(since) ? moved : since;
556
+ const etag = /^etag:\s*(.+)$/im.exec(headers)?.[1]?.trim();
557
+ ctx.state.set(name, {
558
+ since: next,
559
+ started,
560
+ ...etag && next === since ? { etag } : {}
561
+ });
562
+ }
563
+ /** Polls default-branch CI checks, emitting a trigger only when the set of failing checks changes after the initial baseline. */
564
+ async function pollBranchCi(ctx, round) {
565
+ const commands = ctx.commands.withSignal(round.signal);
566
+ const { default_branch: branch } = await commands.api(looseObject({ default_branch: string() }), "");
567
+ const head = await commands.api(looseObject({ sha: string() }), `commits/${encodeURIComponent(branch)}`);
568
+ const snapshot = await pullChecks(commands, head.sha, round.tally);
569
+ const previous = ctx.state.get(KV.github.branchCi);
570
+ const known = previous?.branch === branch ? previous.failures : void 0;
571
+ const failures = ciFailures(snapshot);
572
+ const counts = ciCounts(snapshot);
573
+ const now = failures.length || !counts.running ? failures : known ?? [];
574
+ let revision = previous?.revision ?? 0;
575
+ if (known && JSON.stringify(now) !== JSON.stringify(known)) {
576
+ revision += 1;
577
+ const summary = ciSummary(snapshot, branch, `github/commits/${head.sha}/checks`);
578
+ const url = `https://github.com/${ctx.deployment.repository}/commit/${head.sha}`;
579
+ await ctx.receive(branchCiTrigger(branch, head.sha, revision, summary, url));
580
+ ctx.threads.describe(`github:ci:${branch}`, {
581
+ label: `CI on ${branch}`,
582
+ facts: { ci: ciFact(counts) }
583
+ });
584
+ }
585
+ ctx.state.set(KV.github.branchCi, {
586
+ branch,
587
+ failures: now,
588
+ revision
589
+ });
590
+ }
591
+ /**
592
+ * One polling round: notifications, then observed repository streams, then the PRs of open tickets with their CI, then
593
+ * subscribed Threads. Each step and each Issue, PR, or Thread fails independently so all steps run before the round fails.
594
+ * Other read failures (including 404) fail the round toward the error threshold, whereas deleted (410) or abandoned
595
+ * (24h+) threads are recorded only on the thread and in health detail.
596
+ * @returns the polling interval requested by GitHub, in seconds.
597
+ */
598
+ async function pollRound(ctx, signal) {
599
+ const round = newRound(signal);
600
+ const notified = await pollNotifications(ctx, round).then((value) => ({ value }), (error) => ({ error }));
601
+ await pollRepository(ctx, round);
602
+ await pollTicketPulls(ctx, signal, round);
603
+ await pollThreads(ctx, signal, round);
604
+ if ("error" in notified) throw notified.error;
605
+ const { count, interval } = notified.value;
606
+ if (signal?.aborted) return interval;
607
+ if (round.failures.length) throw new Error(`${round.failures.length} GitHub reads failed: ${round.failures.slice(0, 3).join("; ")}`);
608
+ const named = (numbers) => [...new Set(numbers)].sort((a, b) => a - b).map((number) => `#${number}`).join(", ");
609
+ const dropped = (round.dropped.length ? `; deleted on GitHub: ${named(round.dropped)}` : "") + (round.abandoned.length ? `; dropped after their reads kept failing: ${named(round.abandoned)}` : "") + (round.tally.skipped ? `; ${round.tally.skipped} unreadable items skipped` : "");
610
+ ctx.report("github", {
611
+ state: "ready",
612
+ detail: `${count} notifications; next poll ≥ ${interval}s${dropped}`,
613
+ at: round.started
614
+ });
615
+ return interval;
616
+ }
617
+ /** Reads notifications since the cursor and each Issue or PR they name, advancing the cursor atomically with the retry map for failed reads; deleted (410) Issues are dropped once their error is recorded. */
618
+ async function pollNotifications(ctx, round) {
619
+ const { state } = ctx;
620
+ const commands = ctx.commands.withSignal(round.signal);
621
+ const since = state.get(KV.github.since) ?? round.started;
622
+ if (state.get(KV.github.since) === void 0) state.set(KV.github.since, since);
623
+ const { repository } = ctx.deployment;
624
+ const path = `notifications?all=true&per_page=50&since=${encodeURIComponent(since)}`;
625
+ const raw = await commands.api(path, "--include");
626
+ const split = raw.search(/\r?\n\r?\n/);
627
+ if (split < 0) throw new Error("GitHub returned no HTTP headers");
628
+ const headers = raw.slice(0, split);
629
+ const body = raw.slice(split).trim();
630
+ const asked = Number(/x-poll-interval:\s*(\d+)/i.exec(headers)?.[1] ?? 60);
631
+ const interval = Math.max(60, Number.isFinite(asked) ? asked : 60);
632
+ const notifications = /rel="next"/.test(headers) ? await commands.pages(NotificationSchema, path, round.tally) : validItems(NotificationSchema, array(unknown()).parse(JSON.parse(body)), round.tally);
633
+ const retries = { ...state.get(KV.github.retries) };
634
+ const items = new Map(Object.entries(retries).map(([number, retry]) => [Number(number), retry]));
635
+ for (const notification of notifications.reverse()) {
636
+ if (!sameName(notification.repository.full_name, repository)) continue;
637
+ const address = githubAddress(notification.subject.url ?? "", repository);
638
+ if (!address || address.discussion || items.has(address.number)) continue;
639
+ const { number, pr } = address;
640
+ items.set(number, {
641
+ since: [state.get(KV.github.cursor(number)) ?? since, since].sort().at(-1),
642
+ pr
643
+ });
644
+ }
645
+ for (const [number, item] of items) {
646
+ if (stopped(ctx, round)) return {
647
+ count: notifications.length,
648
+ interval
649
+ };
650
+ round.read.add(number);
651
+ try {
652
+ await readNotified(ctx, round, number, item);
653
+ delete retries[number];
654
+ } catch (error) {
655
+ if (stopped(ctx, round)) return {
656
+ count: notifications.length,
657
+ interval
658
+ };
659
+ const retry = {
660
+ ...item,
661
+ failedAt: item.failedAt ?? round.started,
662
+ attempts: (item.attempts ?? 0) + 1
663
+ };
664
+ const abandoned = Date.parse(round.started) - Date.parse(retry.failedAt) >= RETRY_WINDOW_MS;
665
+ failed(ctx, round, number, error, abandoned);
666
+ if (!deleted(error) && !abandoned) {
667
+ retries[number] = retry;
668
+ continue;
669
+ }
670
+ delete retries[number];
671
+ }
672
+ }
673
+ ctx.atomic(() => {
674
+ state.set(KV.github.since, cursorAt(round.started));
675
+ state.set(KV.github.retries, retries);
676
+ });
677
+ return {
678
+ count: notifications.length,
679
+ interval
680
+ };
681
+ }
682
+ async function readNotified(ctx, round, number, item) {
683
+ const { since } = item;
684
+ let isPr = item.pr;
685
+ if (!isPr) {
686
+ const issue = await ctx.commands.withSignal(round.signal).api(IssueSchema, `issues/${number}`);
687
+ isPr = !!issue.pull_request;
688
+ if (!isPr) {
689
+ const { login } = ctx.deployment.github;
690
+ if (Date.parse(issue.created_at) >= Date.parse(since) && !sameName(issue.user.login, login)) await ctx.receive(issueTrigger(issue, login));
691
+ await intakeComments(ctx, {
692
+ number,
693
+ isPr: false,
694
+ url: issue.html_url
695
+ }, since, round);
696
+ nameThread(ctx, number, issue.title);
697
+ }
698
+ }
699
+ if (isPr) {
700
+ const pr = await pull(ctx, round, number);
701
+ const { login } = ctx.deployment.github;
702
+ if (pr.created_at && Date.parse(pr.created_at) >= Date.parse(since) && !sameName(pr.user.login, login)) {
703
+ const opened = {
704
+ number,
705
+ title: pr.title ?? "",
706
+ body: pr.body,
707
+ user: pr.user,
708
+ created_at: pr.created_at,
709
+ html_url: pr.html_url
710
+ };
711
+ await ctx.receive(pullTrigger(opened, login));
712
+ }
713
+ await intakeComments(ctx, {
714
+ number,
715
+ isPr: true,
716
+ url: pr.html_url,
717
+ prAuthor: pr.user.login
718
+ }, since, round);
719
+ if (pr.title) nameThread(ctx, number, pr.title);
720
+ }
721
+ ctx.state.set(KV.github.cursor(number), cursorAt(round.started));
722
+ ctx.threads.patch(`github:${number}`, { pollError: void 0 });
723
+ }
724
+ /** Reads and observes a PR at most once per polling round. */
725
+ function pull(ctx, round, number) {
726
+ let read = round.pulls.get(number);
727
+ if (!read) {
728
+ read = readPull(ctx, number, round.signal).then(async (pr) => {
729
+ await observePull(ctx, pr);
730
+ return pr;
731
+ });
732
+ round.pulls.set(number, read);
733
+ }
734
+ return read;
735
+ }
736
+ async function intakeComments(ctx, ref, since, round) {
737
+ const commands = ctx.commands.withSignal(round.signal);
738
+ const { login } = ctx.deployment.github;
739
+ const kinds = ref.isPr ? [
740
+ "conversation",
741
+ "inline",
742
+ "review"
743
+ ] : ["conversation"];
744
+ for (const kind of kinds) {
745
+ const suffix = `&since=${encodeURIComponent(since)}`;
746
+ const path = kind === "conversation" ? `issues/${ref.number}/comments?per_page=100${suffix}` : kind === "inline" ? `pulls/${ref.number}/comments?per_page=100${suffix}` : `pulls/${ref.number}/reviews?per_page=100`;
747
+ const comments = await commands.pages(CommentSchema$1, path, round.tally);
748
+ for (const comment of comments) {
749
+ if (stopped(ctx, round)) throw new Error("The GitHub Source stopped during the poll");
750
+ const timestamp = Date.parse(comment.updated_at ?? comment.submitted_at ?? comment.created_at ?? "");
751
+ if (!Number.isFinite(timestamp) || timestamp < Date.parse(since)) continue;
752
+ if (sameName(comment.user.login, login)) continue;
753
+ const trigger = commentTrigger(comment, ref, kind, login);
754
+ if (trigger.text) await ctx.receive(trigger);
755
+ }
756
+ }
757
+ }
758
+ async function pollTicketPulls(ctx, signal, round = newRound(signal)) {
759
+ const numbers = [...new Set(ctx.tickets.open().flatMap((ticket) => pullsOf(ticket, true)))];
760
+ for (const number of numbers) {
761
+ if (stopped(ctx, round)) return;
762
+ try {
763
+ const pr = await pull(ctx, round, number);
764
+ if (pr.state !== "closed" && !pr.merged) await pollChecks(ctx, round, number, pr);
765
+ } catch (error) {
766
+ if (!stopped(ctx, round)) failed(ctx, round, number, error);
767
+ }
768
+ }
769
+ }
770
+ /** Polls a PR's CI checks; only changes after the first baseline observation become Triggers. */
771
+ async function pollChecks(ctx, round, number, pr) {
772
+ const snapshot = await pullChecks(ctx.commands.withSignal(round.signal), pr.head.sha, round.tally);
773
+ const fingerprint = key(JSON.stringify(snapshot));
774
+ const previous = ctx.state.get(KV.github.ci(number));
775
+ if (previous?.fingerprint === fingerprint) return;
776
+ const revision = previous ? previous.revision + 1 : 0;
777
+ if (previous) await ctx.receive(ciTrigger(pr, revision, ciSummary(snapshot, `PR #${number}`, `github/pulls/${number}/checks`)));
778
+ ctx.state.set(KV.github.ci(number), {
779
+ fingerprint,
780
+ revision
781
+ });
782
+ ctx.threads.describe(`github:${number}`, { facts: { ci: ciFact(ciCounts(snapshot)) } });
783
+ }
784
+ function nameThread(ctx, number, title) {
785
+ ctx.threads.patch(`github:${number}`, { title });
786
+ ctx.threads.describe(`github:${number}`, {
787
+ label: `#${number} ${title}`,
788
+ facts: { number: { value: `#${number}` } }
789
+ });
790
+ }
791
+ /** Polls subscribed Issue and PR Threads not already read in this round; a deleted (410) Issue is still polled within the horizon (costing one read) and does not fail the round. */
792
+ async function pollThreads(ctx, signal, round = newRound(signal)) {
793
+ await pollThreads$1(ctx, SOURCE, () => stopped(ctx, round), async (thread) => {
794
+ try {
795
+ await pollThread(ctx, round, thread);
796
+ } catch (error) {
797
+ if (stopped(ctx, round)) throw error;
798
+ if (deleted(error)) round.dropped.push(Number(thread.ref.id));
799
+ else round.failures.push(`${thread.key}: ${String(error)}`);
800
+ throw error;
801
+ }
802
+ }, (thread) => round.read.has(Number(thread.ref.id)));
803
+ }
804
+ async function pollThread(ctx, round, thread) {
805
+ if (thread.ref.id.startsWith("ci:")) return;
806
+ const number = Number(thread.ref.id);
807
+ if (!number) throw new Error("Subscribed thread has no GitHub issue or PR number");
808
+ round.read.add(number);
809
+ if (typeof thread.meta.isPr !== "boolean" || typeof thread.meta.title !== "string") {
810
+ const issue = await ctx.commands.withSignal(round.signal).api(IssueSchema, `issues/${number}`);
811
+ ctx.threads.patch(thread.key, {
812
+ number,
813
+ isPr: !!issue.pull_request,
814
+ url: issue.html_url
815
+ });
816
+ if (issue.title) nameThread(ctx, number, issue.title);
817
+ thread = ctx.threads.get(thread.key) ?? thread;
818
+ }
819
+ const checkpoint = KV.github.cursor(number);
820
+ const since = ctx.state.get(checkpoint) ?? thread.createdAt;
821
+ if (thread.meta.isPr === true) {
822
+ const pr = await pull(ctx, round, number);
823
+ await intakeComments(ctx, {
824
+ number,
825
+ isPr: true,
826
+ url: pr.html_url,
827
+ prAuthor: pr.user.login
828
+ }, since, round);
829
+ } else await intakeComments(ctx, {
830
+ number,
831
+ isPr: false
832
+ }, since, round);
833
+ ctx.state.set(checkpoint, cursorAt(round.started));
834
+ }
835
+ //#endregion
836
+ export { apply, commentTrigger, githubKind, inject, name, pollFailure, pollRound, pollThreads, pollTicketPulls };