@geminixiang/mikan 1.0.0-beta.13 → 1.0.0-beta.14

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 (275) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +10 -10
  3. package/dist/adapters/discord/bot.d.ts.map +1 -1
  4. package/dist/adapters/discord/bot.js +26 -67
  5. package/dist/adapters/discord/bot.js.map +1 -1
  6. package/dist/adapters/discord/context.d.ts.map +1 -1
  7. package/dist/adapters/discord/context.js +17 -43
  8. package/dist/adapters/discord/context.js.map +1 -1
  9. package/dist/adapters/github/bot.d.ts +69 -1
  10. package/dist/adapters/github/bot.d.ts.map +1 -1
  11. package/dist/adapters/github/bot.js +428 -57
  12. package/dist/adapters/github/bot.js.map +1 -1
  13. package/dist/adapters/github/client.d.ts +32 -1
  14. package/dist/adapters/github/client.d.ts.map +1 -1
  15. package/dist/adapters/github/client.js +79 -0
  16. package/dist/adapters/github/client.js.map +1 -1
  17. package/dist/adapters/github/cloudbuild.d.ts +24 -0
  18. package/dist/adapters/github/cloudbuild.d.ts.map +1 -0
  19. package/dist/adapters/github/cloudbuild.js +71 -0
  20. package/dist/adapters/github/cloudbuild.js.map +1 -0
  21. package/dist/adapters/github/context.d.ts.map +1 -1
  22. package/dist/adapters/github/context.js +34 -31
  23. package/dist/adapters/github/context.js.map +1 -1
  24. package/dist/adapters/github/gcp-auth.d.ts +31 -0
  25. package/dist/adapters/github/gcp-auth.d.ts.map +1 -0
  26. package/dist/adapters/github/gcp-auth.js +196 -0
  27. package/dist/adapters/github/gcp-auth.js.map +1 -0
  28. package/dist/adapters/github/ids.d.ts +9 -0
  29. package/dist/adapters/github/ids.d.ts.map +1 -1
  30. package/dist/adapters/github/ids.js +14 -0
  31. package/dist/adapters/github/ids.js.map +1 -1
  32. package/dist/adapters/github/repo.d.ts +13 -2
  33. package/dist/adapters/github/repo.d.ts.map +1 -1
  34. package/dist/adapters/github/repo.js +80 -2
  35. package/dist/adapters/github/repo.js.map +1 -1
  36. package/dist/adapters/github/tool-pack.d.ts +3 -1
  37. package/dist/adapters/github/tool-pack.d.ts.map +1 -1
  38. package/dist/adapters/github/tool-pack.js +30 -4
  39. package/dist/adapters/github/tool-pack.js.map +1 -1
  40. package/dist/{tools/github-checks.d.ts → adapters/github/tools/checks.d.ts} +4 -2
  41. package/dist/adapters/github/tools/checks.d.ts.map +1 -0
  42. package/dist/{tools/github-checks.js → adapters/github/tools/checks.js} +28 -11
  43. package/dist/adapters/github/tools/checks.js.map +1 -0
  44. package/dist/adapters/github/tools/issue.d.ts +22 -0
  45. package/dist/adapters/github/tools/issue.d.ts.map +1 -0
  46. package/dist/adapters/github/tools/issue.js +59 -0
  47. package/dist/adapters/github/tools/issue.js.map +1 -0
  48. package/dist/{tools/github-pr.d.ts → adapters/github/tools/pr.d.ts} +2 -2
  49. package/dist/adapters/github/tools/pr.d.ts.map +1 -0
  50. package/dist/{tools/github-pr.js → adapters/github/tools/pr.js} +8 -4
  51. package/dist/adapters/github/tools/pr.js.map +1 -0
  52. package/dist/adapters/github/tools/read.d.ts +22 -0
  53. package/dist/adapters/github/tools/read.d.ts.map +1 -0
  54. package/dist/adapters/github/tools/read.js +152 -0
  55. package/dist/adapters/github/tools/read.js.map +1 -0
  56. package/dist/adapters/github/tools/review-reply.d.ts +19 -0
  57. package/dist/adapters/github/tools/review-reply.d.ts.map +1 -0
  58. package/dist/adapters/github/tools/review-reply.js +49 -0
  59. package/dist/adapters/github/tools/review-reply.js.map +1 -0
  60. package/dist/adapters/github/tools/sync.d.ts +17 -0
  61. package/dist/adapters/github/tools/sync.d.ts.map +1 -0
  62. package/dist/adapters/github/tools/sync.js +45 -0
  63. package/dist/adapters/github/tools/sync.js.map +1 -0
  64. package/dist/adapters/github/types.d.ts +174 -1
  65. package/dist/adapters/github/types.d.ts.map +1 -1
  66. package/dist/adapters/github/types.js.map +1 -1
  67. package/dist/adapters/intake.d.ts +16 -5
  68. package/dist/adapters/intake.d.ts.map +1 -1
  69. package/dist/adapters/intake.js +79 -22
  70. package/dist/adapters/intake.js.map +1 -1
  71. package/dist/adapters/slack/bot.d.ts +7 -6
  72. package/dist/adapters/slack/bot.d.ts.map +1 -1
  73. package/dist/adapters/slack/bot.js +65 -135
  74. package/dist/adapters/slack/bot.js.map +1 -1
  75. package/dist/adapters/slack/context.d.ts.map +1 -1
  76. package/dist/adapters/slack/context.js +3 -15
  77. package/dist/adapters/slack/context.js.map +1 -1
  78. package/dist/adapters/slack/response-lifecycle.d.ts.map +1 -1
  79. package/dist/adapters/slack/response-lifecycle.js +7 -16
  80. package/dist/adapters/slack/response-lifecycle.js.map +1 -1
  81. package/dist/adapters/slack/session.d.ts.map +1 -1
  82. package/dist/adapters/slack/session.js +5 -8
  83. package/dist/adapters/slack/session.js.map +1 -1
  84. package/dist/adapters/streaming.d.ts +4 -0
  85. package/dist/adapters/streaming.d.ts.map +1 -1
  86. package/dist/adapters/streaming.js +11 -0
  87. package/dist/adapters/streaming.js.map +1 -1
  88. package/dist/adapters/telegram/bot.d.ts +0 -2
  89. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  90. package/dist/adapters/telegram/bot.js +16 -62
  91. package/dist/adapters/telegram/bot.js.map +1 -1
  92. package/dist/adapters/telegram/context.d.ts.map +1 -1
  93. package/dist/adapters/telegram/context.js +8 -28
  94. package/dist/adapters/telegram/context.js.map +1 -1
  95. package/dist/adapters/types.d.ts +28 -2
  96. package/dist/adapters/types.d.ts.map +1 -1
  97. package/dist/adapters/types.js.map +1 -1
  98. package/dist/agent.d.ts +21 -4
  99. package/dist/agent.d.ts.map +1 -1
  100. package/dist/agent.js +22 -9
  101. package/dist/agent.js.map +1 -1
  102. package/dist/commands/admin.d.ts.map +1 -1
  103. package/dist/commands/admin.js +2 -1
  104. package/dist/commands/admin.js.map +1 -1
  105. package/dist/commands/auto-reply.d.ts.map +1 -1
  106. package/dist/commands/auto-reply.js +2 -6
  107. package/dist/commands/auto-reply.js.map +1 -1
  108. package/dist/commands/extensions.d.ts.map +1 -1
  109. package/dist/commands/extensions.js +2 -1
  110. package/dist/commands/extensions.js.map +1 -1
  111. package/dist/commands/login.d.ts +2 -1
  112. package/dist/commands/login.d.ts.map +1 -1
  113. package/dist/commands/login.js +30 -1
  114. package/dist/commands/login.js.map +1 -1
  115. package/dist/commands/manifest.d.ts +72 -0
  116. package/dist/commands/manifest.d.ts.map +1 -0
  117. package/dist/commands/manifest.js +121 -0
  118. package/dist/commands/manifest.js.map +1 -0
  119. package/dist/commands/model.d.ts.map +1 -1
  120. package/dist/commands/model.js +2 -1
  121. package/dist/commands/model.js.map +1 -1
  122. package/dist/commands/new.d.ts.map +1 -1
  123. package/dist/commands/new.js +2 -1
  124. package/dist/commands/new.js.map +1 -1
  125. package/dist/commands/sandbox.d.ts.map +1 -1
  126. package/dist/commands/sandbox.js +3 -2
  127. package/dist/commands/sandbox.js.map +1 -1
  128. package/dist/commands/text.d.ts +2 -0
  129. package/dist/commands/text.d.ts.map +1 -0
  130. package/dist/commands/text.js +16 -0
  131. package/dist/commands/text.js.map +1 -0
  132. package/dist/commands/types.d.ts +12 -1
  133. package/dist/commands/types.d.ts.map +1 -1
  134. package/dist/commands/types.js.map +1 -1
  135. package/dist/config.d.ts +1 -1
  136. package/dist/config.d.ts.map +1 -1
  137. package/dist/config.js +49 -54
  138. package/dist/config.js.map +1 -1
  139. package/dist/events.d.ts +10 -0
  140. package/dist/events.d.ts.map +1 -1
  141. package/dist/events.js +14 -5
  142. package/dist/events.js.map +1 -1
  143. package/dist/execution-resolver.d.ts.map +1 -1
  144. package/dist/execution-resolver.js +3 -3
  145. package/dist/execution-resolver.js.map +1 -1
  146. package/dist/index.d.ts +1 -1
  147. package/dist/index.d.ts.map +1 -1
  148. package/dist/index.js.map +1 -1
  149. package/dist/main.js +25 -5
  150. package/dist/main.js.map +1 -1
  151. package/dist/provisioner.d.ts.map +1 -1
  152. package/dist/provisioner.js +2 -5
  153. package/dist/provisioner.js.map +1 -1
  154. package/dist/runtime/conversation-runtime.d.ts +1 -1
  155. package/dist/runtime/conversation-runtime.d.ts.map +1 -1
  156. package/dist/runtime/conversation-runtime.js +230 -37
  157. package/dist/runtime/conversation-runtime.js.map +1 -1
  158. package/dist/runtime/types.d.ts +19 -4
  159. package/dist/runtime/types.d.ts.map +1 -1
  160. package/dist/runtime/types.js.map +1 -1
  161. package/dist/sandbox/cloudflare.d.ts +2 -0
  162. package/dist/sandbox/cloudflare.d.ts.map +1 -1
  163. package/dist/sandbox/cloudflare.js +7 -0
  164. package/dist/sandbox/cloudflare.js.map +1 -1
  165. package/dist/sandbox/container.d.ts +2 -0
  166. package/dist/sandbox/container.d.ts.map +1 -1
  167. package/dist/sandbox/container.js +7 -1
  168. package/dist/sandbox/container.js.map +1 -1
  169. package/dist/sandbox/firecracker.d.ts +2 -0
  170. package/dist/sandbox/firecracker.d.ts.map +1 -1
  171. package/dist/sandbox/firecracker.js +7 -1
  172. package/dist/sandbox/firecracker.js.map +1 -1
  173. package/dist/sandbox/host.d.ts +2 -0
  174. package/dist/sandbox/host.d.ts.map +1 -1
  175. package/dist/sandbox/host.js +11 -0
  176. package/dist/sandbox/host.js.map +1 -1
  177. package/dist/sandbox/identity.d.ts +21 -0
  178. package/dist/sandbox/identity.d.ts.map +1 -0
  179. package/dist/sandbox/identity.js +35 -0
  180. package/dist/sandbox/identity.js.map +1 -0
  181. package/dist/sandbox/types.d.ts +12 -0
  182. package/dist/sandbox/types.d.ts.map +1 -1
  183. package/dist/sandbox/types.js.map +1 -1
  184. package/dist/sandbox/utils.d.ts +11 -0
  185. package/dist/sandbox/utils.d.ts.map +1 -1
  186. package/dist/sandbox/utils.js +35 -0
  187. package/dist/sandbox/utils.js.map +1 -1
  188. package/dist/sessions/agent-memory-file-manager.d.ts.map +1 -1
  189. package/dist/sessions/agent-memory-file-manager.js +16 -19
  190. package/dist/sessions/agent-memory-file-manager.js.map +1 -1
  191. package/dist/sessions/history-line.d.ts +20 -0
  192. package/dist/sessions/history-line.d.ts.map +1 -0
  193. package/dist/sessions/history-line.js +24 -0
  194. package/dist/sessions/history-line.js.map +1 -0
  195. package/dist/sessions/policy.d.ts.map +1 -1
  196. package/dist/sessions/policy.js +4 -3
  197. package/dist/sessions/policy.js.map +1 -1
  198. package/dist/sessions/session-key.d.ts +31 -0
  199. package/dist/sessions/session-key.d.ts.map +1 -0
  200. package/dist/sessions/session-key.js +44 -0
  201. package/dist/sessions/session-key.js.map +1 -0
  202. package/dist/sessions/store.d.ts.map +1 -1
  203. package/dist/sessions/store.js +2 -2
  204. package/dist/sessions/store.js.map +1 -1
  205. package/dist/tools/bash.d.ts +3 -1
  206. package/dist/tools/bash.d.ts.map +1 -1
  207. package/dist/tools/bash.js +24 -17
  208. package/dist/tools/bash.js.map +1 -1
  209. package/dist/tools/edit.d.ts.map +1 -1
  210. package/dist/tools/edit.js +9 -12
  211. package/dist/tools/edit.js.map +1 -1
  212. package/dist/tools/event.d.ts +1 -1
  213. package/dist/tools/event.d.ts.map +1 -1
  214. package/dist/tools/event.js +33 -4
  215. package/dist/tools/event.js.map +1 -1
  216. package/dist/tools/index.js +1 -1
  217. package/dist/tools/index.js.map +1 -1
  218. package/dist/tools/types.d.ts +6 -1
  219. package/dist/tools/types.d.ts.map +1 -1
  220. package/dist/tools/types.js.map +1 -1
  221. package/dist/tools/write.d.ts.map +1 -1
  222. package/dist/tools/write.js +1 -8
  223. package/dist/tools/write.js.map +1 -1
  224. package/dist/types.d.ts +13 -6
  225. package/dist/types.d.ts.map +1 -1
  226. package/dist/types.js.map +1 -1
  227. package/dist/vault/disabled.d.ts +9 -0
  228. package/dist/vault/disabled.d.ts.map +1 -0
  229. package/dist/vault/disabled.js +23 -0
  230. package/dist/vault/disabled.js.map +1 -0
  231. package/dist/vault/index.d.ts.map +1 -1
  232. package/dist/vault/index.js +2 -7
  233. package/dist/vault/index.js.map +1 -1
  234. package/dist/vault/routing.d.ts +1 -0
  235. package/dist/vault/routing.d.ts.map +1 -1
  236. package/dist/vault/routing.js +3 -10
  237. package/dist/vault/routing.js.map +1 -1
  238. package/dist/web/admin/portal.d.ts +27 -0
  239. package/dist/web/admin/portal.d.ts.map +1 -1
  240. package/dist/web/admin/portal.js +87 -126
  241. package/dist/web/admin/portal.js.map +1 -1
  242. package/dist/web/admin/types.d.ts +3 -0
  243. package/dist/web/admin/types.d.ts.map +1 -1
  244. package/dist/web/admin/types.js.map +1 -1
  245. package/dist/web/login/oauth.d.ts +2 -3
  246. package/dist/web/login/oauth.d.ts.map +1 -1
  247. package/dist/web/login/oauth.js +0 -31
  248. package/dist/web/login/oauth.js.map +1 -1
  249. package/dist/web/login/types.d.ts +0 -11
  250. package/dist/web/login/types.d.ts.map +1 -1
  251. package/dist/web/login/types.js.map +1 -1
  252. package/dist/web/server.d.ts.map +1 -1
  253. package/dist/web/server.js +7 -0
  254. package/dist/web/server.js.map +1 -1
  255. package/dist/web/session-view/command.d.ts.map +1 -1
  256. package/dist/web/session-view/command.js +4 -1
  257. package/dist/web/session-view/command.js.map +1 -1
  258. package/dist/web/session-view/portal.d.ts.map +1 -1
  259. package/dist/web/session-view/portal.js +3 -2
  260. package/dist/web/session-view/portal.js.map +1 -1
  261. package/dist/web/session-view/service.d.ts.map +1 -1
  262. package/dist/web/session-view/service.js +2 -1
  263. package/dist/web/session-view/service.js.map +1 -1
  264. package/dist/web/session-view/types.d.ts +2 -1
  265. package/dist/web/session-view/types.d.ts.map +1 -1
  266. package/dist/web/session-view/types.js.map +1 -1
  267. package/package.json +1 -1
  268. package/dist/runtime/agent-run-controller.d.ts +0 -31
  269. package/dist/runtime/agent-run-controller.d.ts.map +0 -1
  270. package/dist/runtime/agent-run-controller.js +0 -195
  271. package/dist/runtime/agent-run-controller.js.map +0 -1
  272. package/dist/tools/github-checks.d.ts.map +0 -1
  273. package/dist/tools/github-checks.js.map +0 -1
  274. package/dist/tools/github-pr.d.ts.map +0 -1
  275. package/dist/tools/github-pr.js.map +0 -1
@@ -5,19 +5,22 @@ import * as log from "../../log.js";
5
5
  import { ensureDirExists, readJsonSchemaFileIfExists } from "../../utils/file-guards.js";
6
6
  import { atomicWritePrivateFile } from "../../utils/fs-atomic.js";
7
7
  import { resolveChatSessionKey } from "../../sessions/policy.js";
8
- import { formatNothingRunning } from "../../platform-messages.js";
9
- import { appendBotResponseLog, appendChannelLog, MessagingEventQueue, resolveStopTarget, splitText, withRetry, } from "../shared.js";
8
+ import { appendBotResponseLog, appendChannelLog, MessagingEventQueue, splitText, withRetry, } from "../shared.js";
10
9
  import { processMessageIntake } from "../intake.js";
11
10
  import { GithubApiError, GithubClient, githubIsRateLimited } from "./client.js";
11
+ import { CLOUD_BUILD_APP_SLUG, CloudBuildLogUnavailableError, fetchCloudBuildLog, isCloudBuildId, projectFromDetailsUrl, } from "./cloudbuild.js";
12
12
  import { createGithubAdapters } from "./context.js";
13
- import { cloneRepo, GITHUB_PUSH_BRANCH_PATTERN, pushBranch } from "./repo.js";
14
- import { buildGithubConversationId, GITHUB_ISSUE_BODY_TS, parseGithubConversationId, } from "./ids.js";
13
+ import { cloneRepo, GITHUB_PUSH_BRANCH_PATTERN, pushBranch, syncRepo } from "./repo.js";
14
+ import { buildGithubConversationId, GITHUB_ISSUE_BODY_TS, githubReviewCommentTs, parseGithubConversationId, parseReviewCommentTs, } from "./ids.js";
15
15
  const SyncStateSchema = Type.Object({
16
16
  repos: Type.Record(Type.String(), Type.Object({
17
17
  baseline: Type.String(),
18
18
  cursor: Type.String(),
19
19
  seenComments: Type.Array(Type.Number()),
20
20
  seenIssues: Type.Array(Type.Number()),
21
+ // Optional: state files written before review-comment polling must keep
22
+ // loading — a schema failure re-baselines and silently drops dedup state.
23
+ seenReviewComments: Type.Optional(Type.Array(Type.Number())),
21
24
  })),
22
25
  });
23
26
  /**
@@ -94,6 +97,18 @@ function issueNumberFromUrl(issueUrl) {
94
97
  }
95
98
  return Number(match[1]);
96
99
  }
100
+ /** PR number from a review comment's `pull_request_url` (…/repos/o/r/pulls/42). */
101
+ function prNumberFromUrl(pullRequestUrl) {
102
+ const match = /\/pulls\/(\d+)$/.exec(pullRequestUrl);
103
+ if (!match) {
104
+ throw new Error(`Cannot parse PR number from ${pullRequestUrl}`);
105
+ }
106
+ return Number(match[1]);
107
+ }
108
+ /** Keep the tail: a review comment anchors to the hunk's final lines. */
109
+ const MAX_DIFF_HUNK_CHARS = 1500;
110
+ const MAX_THREAD_TURNS = 10;
111
+ const MAX_THREAD_TURN_CHARS = 500;
97
112
  export class GithubMessagingBot {
98
113
  constructor(handler, config, client) {
99
114
  this.appSlug = null;
@@ -102,6 +117,8 @@ export class GithubMessagingBot {
102
117
  this.queues = new Map();
103
118
  this.repoState = new Map();
104
119
  this.permissionCache = new Map();
120
+ /** Cloud Build ids seen in getChecks summaries → their GCP project. */
121
+ this.buildRef = new Map();
105
122
  this.pollInFlight = false;
106
123
  this.handler = handler;
107
124
  this.config = config;
@@ -151,8 +168,15 @@ export class GithubMessagingBot {
151
168
  cursor: saved.cursor,
152
169
  seenComments: new Set(saved.seenComments),
153
170
  seenIssues: new Set(saved.seenIssues),
171
+ seenReviewComments: new Set(saved.seenReviewComments ?? []),
154
172
  }
155
- : { baseline: now, cursor: now, seenComments: new Set(), seenIssues: new Set() });
173
+ : {
174
+ baseline: now,
175
+ cursor: now,
176
+ seenComments: new Set(),
177
+ seenIssues: new Set(),
178
+ seenReviewComments: new Set(),
179
+ });
156
180
  }
157
181
  this.persistSyncState();
158
182
  setInterval(() => {
@@ -180,9 +204,16 @@ export class GithubMessagingBot {
180
204
  if (!content) {
181
205
  throw new Error(`GitHub does not support reaction '${emoji}' (supported: ${Object.keys(GITHUB_REACTIONS).join(", ")})`);
182
206
  }
183
- await githubRetry(() => messageTs === GITHUB_ISSUE_BODY_TS
184
- ? this.client.createIssueReaction(ref.owner, ref.repo, ref.number, content)
185
- : this.client.createCommentReaction(ref.owner, ref.repo, Number(messageTs), content));
207
+ const reviewCommentId = parseReviewCommentTs(messageTs);
208
+ await githubRetry(() => {
209
+ if (messageTs === GITHUB_ISSUE_BODY_TS) {
210
+ return this.client.createIssueReaction(ref.owner, ref.repo, ref.number, content);
211
+ }
212
+ if (reviewCommentId !== null) {
213
+ return this.client.createReviewCommentReaction(ref.owner, ref.repo, reviewCommentId, content);
214
+ }
215
+ return this.client.createCommentReaction(ref.owner, ref.repo, Number(messageTs), content);
216
+ });
186
217
  }
187
218
  enqueueEvent(event) {
188
219
  const conversationId = event.conversationId;
@@ -203,8 +234,8 @@ export class GithubMessagingBot {
203
234
  name: "github",
204
235
  trustModel: "open-trigger",
205
236
  formattingGuide: "## GitHub Formatting (GitHub Flavored Markdown)\n" +
206
- "Standard Markdown plus tables, task lists, and ```suggestion blocks.\n" +
207
- "Reference issues/PRs as #123 and users as @login.",
237
+ "Standard Markdown plus tables, task lists, fenced code blocks, and ```suggestion blocks.\n" +
238
+ "Reference issues/PRs as #123 and users as @login (mentions notify people — use sparingly).",
208
239
  channels: [],
209
240
  users: [],
210
241
  };
@@ -312,8 +343,41 @@ export class GithubMessagingBot {
312
343
  createdAt: comment.created_at,
313
344
  });
314
345
  }
346
+ // Inline PR review comments live in their own endpoint and id space; the
347
+ // same watermark discipline applies. The bot's own thread replies (the
348
+ // github_review_reply tool) re-surface here and are dropped as Bot-authored.
349
+ const reviewComments = (await this.client.listPullReviewCommentsSince(repo.owner, repo.repo, since)) ?? [];
350
+ for (const comment of reviewComments) {
351
+ if (comment.updated_at > state.cursor) {
352
+ state.cursor = comment.updated_at;
353
+ changed = true;
354
+ }
355
+ if (comment.created_at < state.baseline || state.seenReviewComments.has(comment.id)) {
356
+ continue;
357
+ }
358
+ state.seenReviewComments.add(comment.id);
359
+ changed = true;
360
+ if (comment.user.type === "Bot")
361
+ continue;
362
+ await this.handleIncoming({
363
+ ref: { ...repo, number: prNumberFromUrl(comment.pull_request_url) },
364
+ ts: githubReviewCommentTs(comment.id),
365
+ user: comment.user.login,
366
+ text: comment.body,
367
+ createdAt: comment.created_at,
368
+ isPr: true,
369
+ review: {
370
+ commentId: comment.id,
371
+ path: comment.path,
372
+ line: comment.line,
373
+ diffHunk: comment.diff_hunk,
374
+ inReplyToId: comment.in_reply_to_id,
375
+ },
376
+ });
377
+ }
315
378
  pruneSeen(state.seenComments);
316
379
  pruneSeen(state.seenIssues);
380
+ pruneSeen(state.seenReviewComments);
317
381
  return changed;
318
382
  }
319
383
  // ==========================================================================
@@ -338,6 +402,7 @@ export class GithubMessagingBot {
338
402
  cursor: watermark.cursor,
339
403
  seenComments: [...watermark.seenComments],
340
404
  seenIssues: [...watermark.seenIssues],
405
+ seenReviewComments: [...watermark.seenReviewComments],
341
406
  };
342
407
  }
343
408
  ensureDirExists(dirname(this.config.syncStatePath));
@@ -411,40 +476,9 @@ export class GithubMessagingBot {
411
476
  messageId: item.ts,
412
477
  persistentTopLevel: true,
413
478
  });
414
- if (/^stop$/i.test(cleanedText)) {
415
- this.logToFile(conversationId, {
416
- date: item.createdAt,
417
- ts: item.ts,
418
- user: item.user,
419
- userName: item.user,
420
- text: cleanedText,
421
- attachments: [],
422
- isMessagingBot: false,
423
- });
424
- const stopTarget = resolveStopTarget({ handler: this.handler, conversationId, sessionKey });
425
- if (stopTarget) {
426
- await this.handler.handleStop(stopTarget, conversationId, this);
427
- }
428
- else if (mentioned) {
429
- await this.postMessage(conversationId, formatNothingRunning("github"));
430
- }
431
- return;
432
- }
433
- // First contact: log the issue title/body ahead of the comment so the
434
- // session history starts with what the thread is about. The clone is
435
- // (re)attempted on every trigger while ./repo is missing — a no-op once
436
- // it exists — so a failed first clone (e.g. App permissions granted
437
- // later) heals on the next mention instead of wedging the conversation.
438
- let isPrHint = item.isPr;
439
- if (!participating && item.ts !== GITHUB_ISSUE_BODY_TS) {
440
- const issue = await this.logIssueContext(item.ref, conversationId, item.createdAt);
441
- if (issue)
442
- isPrHint = Boolean(issue.pull_request);
443
- }
444
- if (!existsSync(this.repoDir(conversationId))) {
445
- const isPr = isPrHint ?? (await this.fetchIsPr(item.ref));
446
- await this.ensureRepoClone(item.ref, conversationId, isPr);
447
- }
479
+ const messageText = item.review
480
+ ? await this.formatReviewMessage(item, cleanedText)
481
+ : cleanedText;
448
482
  const eventBase = {
449
483
  type: item.ts === GITHUB_ISSUE_BODY_TS ? "issue" : "message",
450
484
  conversationId,
@@ -453,22 +487,31 @@ export class GithubMessagingBot {
453
487
  sessionKey,
454
488
  user: item.user,
455
489
  userName: item.user,
456
- text: cleanedText,
490
+ text: messageText,
457
491
  };
458
492
  await processMessageIntake({
459
493
  eventBase,
460
494
  workingDir: this.config.workingDir,
461
495
  isAutoReplyCandidate: false,
496
+ // Match on the user-typed text, not the review-decorated messageText.
497
+ magicWord: { text: cleanedText, addressed: mentioned, scopeFallback: "never" },
498
+ busyPolicy: "queue",
462
499
  logEntryBase: {
463
500
  date: item.createdAt,
464
501
  ts: item.ts,
465
502
  user: item.user,
466
503
  userName: item.user,
467
- text: cleanedText,
504
+ text: messageText,
468
505
  isMessagingBot: false,
469
506
  },
470
507
  log: (entry) => this.logToFile(conversationId, entry),
471
- processAttachments: async () => [],
508
+ // GitHub has no attachments; the prep hook is used to lay down the
509
+ // issue context and repo clone only for messages that will dispatch,
510
+ // and before the comment itself is logged.
511
+ processAttachments: async () => {
512
+ await this.prepareConversation(item, conversationId, participating);
513
+ return [];
514
+ },
472
515
  queueKey: conversationId,
473
516
  enqueue: (queueKey, work) => this.getQueue(queueKey).enqueue(work),
474
517
  handler: this.handler,
@@ -476,6 +519,74 @@ export class GithubMessagingBot {
476
519
  createContext: (event) => createGithubAdapters(event, this),
477
520
  });
478
521
  }
522
+ /**
523
+ * First contact: log the issue title/body ahead of the comment so the
524
+ * session history starts with what the thread is about. The clone is
525
+ * (re)attempted on every trigger while ./repo is missing — a no-op once
526
+ * it exists — so a failed first clone (e.g. App permissions granted
527
+ * later) heals on the next mention instead of wedging the conversation.
528
+ */
529
+ async prepareConversation(item, conversationId, participating) {
530
+ let isPrHint = item.isPr;
531
+ if (!participating && item.ts !== GITHUB_ISSUE_BODY_TS) {
532
+ const issue = await this.logIssueContext(item.ref, conversationId, item.createdAt);
533
+ // Only comments arrive without the PR-ness hint; review comments carry
534
+ // isPr: true (they only exist on PRs) and that knowledge is authoritative.
535
+ if (issue && isPrHint === undefined)
536
+ isPrHint = Boolean(issue.pull_request);
537
+ }
538
+ if (!existsSync(this.repoDir(conversationId))) {
539
+ const isPr = isPrHint ?? (await this.fetchIsPr(item.ref));
540
+ await this.ensureRepoClone(item.ref, conversationId, isPr);
541
+ }
542
+ }
543
+ /**
544
+ * Decorates an inline review comment with its diff anchor and (for replies)
545
+ * the thread's earlier turns, so the agent knows which code the feedback is
546
+ * about without hunting through the PR. The rc-<id> in the header is what
547
+ * github_review_reply takes to answer in-thread.
548
+ */
549
+ async formatReviewMessage(item, cleanedText) {
550
+ const review = item.review;
551
+ const location = review.line !== null ? `${review.path}:${review.line}` : review.path;
552
+ const parts = [`[PR review comment ${githubReviewCommentTs(review.commentId)} on ${location}]`];
553
+ if (review.diffHunk) {
554
+ const hunk = review.diffHunk.length > MAX_DIFF_HUNK_CHARS
555
+ ? `…${review.diffHunk.slice(-MAX_DIFF_HUNK_CHARS)}`
556
+ : review.diffHunk;
557
+ parts.push(`\`\`\`diff\n${hunk}\n\`\`\``);
558
+ }
559
+ if (review.inReplyToId !== undefined) {
560
+ const turns = await this.reviewThreadContext(item.ref, review.inReplyToId, review.commentId);
561
+ if (turns.length > 0) {
562
+ parts.push(`Thread so far:\n${turns.join("\n")}`);
563
+ }
564
+ }
565
+ parts.push(cleanedText);
566
+ return parts.join("\n");
567
+ }
568
+ /**
569
+ * Earlier turns of one review thread (root + replies before the trigger),
570
+ * newest-truncated. Fail-soft: context is a nicety, the trigger is not.
571
+ */
572
+ async reviewThreadContext(ref, rootId, beforeId) {
573
+ try {
574
+ const all = await githubRetry(() => this.client.listPullReviewComments(ref.owner, ref.repo, ref.number));
575
+ return all
576
+ .filter((comment) => (comment.id === rootId || comment.in_reply_to_id === rootId) && comment.id < beforeId)
577
+ .slice(-MAX_THREAD_TURNS)
578
+ .map((comment) => {
579
+ const body = comment.body.length > MAX_THREAD_TURN_CHARS
580
+ ? `${comment.body.slice(0, MAX_THREAD_TURN_CHARS)}…`
581
+ : comment.body;
582
+ return `@${comment.user.login}: ${body}`;
583
+ });
584
+ }
585
+ catch (err) {
586
+ log.logWarning(`GitHub: could not fetch review thread context for ${ref.owner}/${ref.repo}#${ref.number}`, err instanceof Error ? err.message : String(err));
587
+ return [];
588
+ }
589
+ }
479
590
  async logIssueContext(ref, conversationId, triggerCreatedAt) {
480
591
  let issue;
481
592
  try {
@@ -519,6 +630,23 @@ export class GithubMessagingBot {
519
630
  return false;
520
631
  }
521
632
  }
633
+ /**
634
+ * The PR's head branch name when it lives in this repo — that is the name
635
+ * the local checkout should carry, so committing on it and calling
636
+ * github_pr pushes back to the PR itself instead of opening a new one.
637
+ * Fork heads (unpushable with the repo-scoped token) and failed lookups
638
+ * return undefined; callers then fall back to the synthetic pr-<n>.
639
+ */
640
+ async fetchPrHeadBranch(ref) {
641
+ try {
642
+ const pr = await githubRetry(() => this.client.getPullRequest(ref.owner, ref.repo, ref.number));
643
+ const sameRepo = pr.head?.repo?.full_name?.toLowerCase() === `${ref.owner}/${ref.repo}`.toLowerCase();
644
+ return sameRepo ? pr.head?.ref : undefined;
645
+ }
646
+ catch {
647
+ return undefined;
648
+ }
649
+ }
522
650
  /**
523
651
  * Clone the conversation's repo into its dir (bind-mounted into the
524
652
  * sandbox) using an ephemeral contents:read token that never leaves the
@@ -532,6 +660,7 @@ export class GithubMessagingBot {
532
660
  const token = await this.client.createScopedInstallationToken(ref.repo, {
533
661
  contents: "read",
534
662
  });
663
+ const prHeadBranch = isPr ? await this.fetchPrHeadBranch(ref) : undefined;
535
664
  await cloneRepo({
536
665
  url: `https://github.com/${ref.owner}/${ref.repo}.git`,
537
666
  dir,
@@ -539,8 +668,9 @@ export class GithubMessagingBot {
539
668
  botLogin: `${this.appSlug}[bot]`,
540
669
  botEmail: this.botEmail ?? `${this.appSlug}[bot]@users.noreply.github.com`,
541
670
  prNumber: isPr ? ref.number : undefined,
671
+ prHeadBranch,
542
672
  });
543
- log.logInfo(`[${conversationId}] Cloned ${ref.owner}/${ref.repo}${isPr ? ` and checked out PR #${ref.number} head` : ""}`);
673
+ log.logInfo(`[${conversationId}] Cloned ${ref.owner}/${ref.repo}${isPr ? ` and checked out PR #${ref.number} head as ${prHeadBranch ?? `pr-${ref.number}`}` : ""}`);
544
674
  }
545
675
  catch (err) {
546
676
  log.logWarning(`GitHub: repo clone failed for ${conversationId}`, err instanceof Error ? err.message : String(err));
@@ -616,15 +746,256 @@ export class GithubMessagingBot {
616
746
  target = pr.head.sha;
617
747
  }
618
748
  const runs = await githubRetry(() => this.client.listCheckRuns(ref.owner, ref.repo, target));
619
- return runs.map((run) => ({
620
- id: run.id,
621
- name: run.name,
622
- status: run.status,
623
- conclusion: run.conclusion,
624
- url: run.html_url,
625
- appSlug: run.app?.slug ?? null,
626
- outputSummary: run.output?.summary?.trim() ? run.output.summary.slice(0, 500) : null,
627
- }));
749
+ return runs.map((run) => {
750
+ const externalId = run.external_id?.trim() || null;
751
+ let buildLogAvailable = false;
752
+ // Cloud Build runs carry their build UUID in external_id and the GCP
753
+ // project in details_url. Remember project-by-build so getBuildLog only
754
+ // serves builds the agent actually saw on this repo's checks.
755
+ if (this.config.cloudBuild &&
756
+ run.app?.slug === CLOUD_BUILD_APP_SLUG &&
757
+ externalId &&
758
+ isCloudBuildId(externalId)) {
759
+ const project = projectFromDetailsUrl(run.details_url) ?? this.config.cloudBuild.projectFallback;
760
+ if (project) {
761
+ this.rememberBuild(externalId, project);
762
+ buildLogAvailable = true;
763
+ }
764
+ }
765
+ return {
766
+ id: run.id,
767
+ name: run.name,
768
+ status: run.status,
769
+ conclusion: run.conclusion,
770
+ url: run.html_url,
771
+ appSlug: run.app?.slug ?? null,
772
+ outputSummary: run.output?.summary?.trim() ? run.output.summary.slice(0, 500) : null,
773
+ externalId,
774
+ buildLogAvailable,
775
+ };
776
+ });
777
+ }
778
+ rememberBuild(buildId, project) {
779
+ // Insertion-ordered Map as a small LRU: re-insert on touch, prune oldest.
780
+ this.buildRef.delete(buildId);
781
+ this.buildRef.set(buildId, { project });
782
+ if (this.buildRef.size > 200) {
783
+ const oldest = this.buildRef.keys().next().value;
784
+ if (oldest !== undefined)
785
+ this.buildRef.delete(oldest);
786
+ }
787
+ }
788
+ /**
789
+ * Backs the Cloud Build log mode of `github_checks`. GCP credentials stay
790
+ * host-side (ADC via GOOGLE_APPLICATION_CREDENTIALS — e.g. a Workload
791
+ * Identity Federation external_account file); the build must have appeared
792
+ * in a github_checks summary first, which is also where its project is
793
+ * learned. Unreadable logs degrade to the console url, never a dead end.
794
+ */
795
+ async getBuildLog(conversationId, buildId) {
796
+ if (!this.config.cloudBuild) {
797
+ throw new Error("Cloud Build log access is not configured on this host (no GCP credentials).");
798
+ }
799
+ const known = this.buildRef.get(buildId);
800
+ if (!known) {
801
+ throw new Error(`Unknown build id ${buildId}: run github_checks first and take a [build …] id from ` +
802
+ `the summary.`);
803
+ }
804
+ let logText;
805
+ try {
806
+ logText = await fetchCloudBuildLog({
807
+ tokenProvider: this.config.cloudBuild.tokenProvider,
808
+ project: known.project,
809
+ buildId,
810
+ });
811
+ }
812
+ catch (err) {
813
+ if (err instanceof CloudBuildLogUnavailableError) {
814
+ throw new Error(`${err.message}${err.consoleUrl ? ` Console: ${err.consoleUrl}` : ""} Use the check's ` +
815
+ `summary/url from github_checks, or reproduce the failure locally in ./repo.`, { cause: err });
816
+ }
817
+ throw err;
818
+ }
819
+ const MAX_LOG_CHARS = 20000;
820
+ return logText.length > MAX_LOG_CHARS
821
+ ? `…(truncated to the last ${MAX_LOG_CHARS} chars)\n${logText.slice(-MAX_LOG_CHARS)}`
822
+ : logText;
823
+ }
824
+ /**
825
+ * Backs the `github_sync` tool: refresh the conversation's ./repo clone
826
+ * from origin with an ephemeral read token. Fetch-only when moving the
827
+ * checkout could lose agent work; the formatted report tells the agent
828
+ * what happened and what to do next.
829
+ */
830
+ async syncRepo(conversationId, branch) {
831
+ const ref = parseGithubConversationId(conversationId);
832
+ const dir = this.repoDir(conversationId);
833
+ if (!existsSync(dir)) {
834
+ throw new Error("This conversation has no ./repo clone to sync.");
835
+ }
836
+ const token = await this.client.createScopedInstallationToken(ref.repo, {
837
+ contents: "read",
838
+ });
839
+ let prNumber;
840
+ let prHeadBranch;
841
+ let defaultBranch;
842
+ if (!branch) {
843
+ if (await this.fetchIsPr(ref)) {
844
+ prNumber = ref.number;
845
+ prHeadBranch = await this.fetchPrHeadBranch(ref);
846
+ }
847
+ else {
848
+ const repository = await githubRetry(() => this.client.getRepository(ref.owner, ref.repo));
849
+ defaultBranch = repository.default_branch;
850
+ }
851
+ }
852
+ const result = await syncRepo({ dir, token, branch, prNumber, prHeadBranch, defaultBranch });
853
+ if (result.updatedCheckout) {
854
+ return `Updated ./repo: branch ${result.target} is now at ${result.fetchedSha.slice(0, 12)}.`;
855
+ }
856
+ const reasons = [];
857
+ if (result.currentBranch !== result.target) {
858
+ reasons.push(`the checkout is on '${result.currentBranch}', not '${result.target}'`);
859
+ }
860
+ if (result.dirty) {
861
+ reasons.push("the working tree has uncommitted changes");
862
+ }
863
+ if (result.localCommits > 0) {
864
+ reasons.push(`'${result.target}' has ${result.localCommits} local commit(s) not on origin`);
865
+ }
866
+ return (`Fetched ${result.target} to FETCH_HEAD (${result.fetchedSha.slice(0, 12)}) but left the ` +
867
+ `checkout alone: ${reasons.join("; ")}. Merge or rebase FETCH_HEAD yourself ` +
868
+ `(e.g. git merge FETCH_HEAD), or commit your work to a pi/* branch first.`);
869
+ }
870
+ /**
871
+ * Backs the `github_read` tool: PR/issue metadata the ./repo clone cannot
872
+ * provide (diff stats, review state, other issues in the repo). Owner/repo
873
+ * always come from the conversation id, so reads never leave this repo.
874
+ */
875
+ async readGithub(conversationId, request) {
876
+ const ref = parseGithubConversationId(conversationId);
877
+ const number = request.number ?? ref.number;
878
+ switch (request.action) {
879
+ case "pr": {
880
+ const pr = await githubRetry(() => this.client.getPullRequest(ref.owner, ref.repo, number));
881
+ return { kind: "pr", pr };
882
+ }
883
+ case "pr_files": {
884
+ const files = await githubRetry(() => this.client.listPullRequestFiles(ref.owner, ref.repo, number));
885
+ return { kind: "pr_files", files };
886
+ }
887
+ case "pr_reviews": {
888
+ const [reviews, threads] = await Promise.all([
889
+ githubRetry(() => this.client.listPullRequestReviews(ref.owner, ref.repo, number)),
890
+ githubRetry(() => this.client.listPullReviewComments(ref.owner, ref.repo, number)),
891
+ ]);
892
+ return { kind: "pr_reviews", reviews, threads };
893
+ }
894
+ case "issue": {
895
+ const issue = await githubRetry(() => this.client.getIssue(ref.owner, ref.repo, number));
896
+ return { kind: "issue", issue };
897
+ }
898
+ case "comments": {
899
+ const comments = await githubRetry(() => this.client.listIssueComments(ref.owner, ref.repo, number));
900
+ return { kind: "comments", comments };
901
+ }
902
+ case "list": {
903
+ const issues = await githubRetry(() => this.client.listIssues(ref.owner, ref.repo, {
904
+ state: request.state,
905
+ labels: request.labels,
906
+ creator: request.creator,
907
+ }));
908
+ return { kind: "list", issues };
909
+ }
910
+ default:
911
+ throw new Error(`Unknown github_read action: ${String(request.action)}`);
912
+ }
913
+ }
914
+ /**
915
+ * Backs the `github_issue` tool: labels, assignees, and open/closed state
916
+ * on any issue of this conversation's repo (triage). Same-repo by
917
+ * construction — owner/repo come from the conversation id; the action enum
918
+ * is closed, so lock/delete/transfer cannot be expressed at all.
919
+ */
920
+ async manageIssue(conversationId, request) {
921
+ const ref = parseGithubConversationId(conversationId);
922
+ const number = request.number ?? ref.number;
923
+ try {
924
+ switch (request.action) {
925
+ case "add_labels": {
926
+ if (!request.labels?.length) {
927
+ throw new Error("add_labels requires a non-empty labels array.");
928
+ }
929
+ await githubRetry(() => this.client.addIssueLabels(ref.owner, ref.repo, number, request.labels));
930
+ return `Added label(s) ${request.labels.join(", ")} to #${number}.`;
931
+ }
932
+ case "remove_label": {
933
+ if (!request.label) {
934
+ throw new Error("remove_label requires a label name.");
935
+ }
936
+ await githubRetry(() => this.client.removeIssueLabel(ref.owner, ref.repo, number, request.label));
937
+ return `Removed label ${request.label} from #${number}.`;
938
+ }
939
+ case "add_assignees": {
940
+ if (!request.assignees?.length) {
941
+ throw new Error("add_assignees requires a non-empty assignees array.");
942
+ }
943
+ await githubRetry(() => this.client.addIssueAssignees(ref.owner, ref.repo, number, request.assignees));
944
+ return `Assigned ${request.assignees.map((login) => `@${login}`).join(", ")} to #${number}.`;
945
+ }
946
+ case "remove_assignees": {
947
+ if (!request.assignees?.length) {
948
+ throw new Error("remove_assignees requires a non-empty assignees array.");
949
+ }
950
+ await githubRetry(() => this.client.removeIssueAssignees(ref.owner, ref.repo, number, request.assignees));
951
+ return `Unassigned ${request.assignees.map((login) => `@${login}`).join(", ")} from #${number}.`;
952
+ }
953
+ case "close": {
954
+ await githubRetry(() => this.client.updateIssueState(ref.owner, ref.repo, number, "closed", request.state_reason));
955
+ return `Closed #${number}${request.state_reason ? ` as ${request.state_reason}` : ""}.`;
956
+ }
957
+ case "reopen": {
958
+ await githubRetry(() => this.client.updateIssueState(ref.owner, ref.repo, number, "open"));
959
+ return `Reopened #${number}.`;
960
+ }
961
+ default:
962
+ throw new Error(`Unknown github_issue action: ${String(request.action)}`);
963
+ }
964
+ }
965
+ catch (err) {
966
+ if (err instanceof GithubApiError && err.status === 404) {
967
+ throw new Error(`Issue #${number} not found in ${ref.owner}/${ref.repo}.`, { cause: err });
968
+ }
969
+ throw err;
970
+ }
971
+ }
972
+ /**
973
+ * Backs the `github_review_reply` tool: answer inside one inline review
974
+ * thread of this conversation's PR. The comment id comes from an
975
+ * `[PR review comment rc-<id> …]` message; GitHub validates it belongs to
976
+ * this PR (404 otherwise, translated into agent-actionable guidance).
977
+ */
978
+ async replyToReviewThread(conversationId, commentId, body) {
979
+ if (!Number.isInteger(commentId) || commentId <= 0) {
980
+ throw new Error("comment_id must be the numeric id from an [PR review comment rc-<id> …] message.");
981
+ }
982
+ const ref = parseGithubConversationId(conversationId);
983
+ const text = body.length > GITHUB_MAX_COMMENT_LENGTH
984
+ ? `${body.slice(0, GITHUB_MAX_COMMENT_LENGTH)}\n…(truncated)`
985
+ : body;
986
+ try {
987
+ const reply = await githubRetry(() => this.client.replyToReviewComment(ref.owner, ref.repo, ref.number, commentId, text));
988
+ return {
989
+ url: `https://github.com/${ref.owner}/${ref.repo}/pull/${ref.number}#discussion_r${reply.id}`,
990
+ };
991
+ }
992
+ catch (err) {
993
+ if (err instanceof GithubApiError && err.status === 404) {
994
+ throw new Error(`Comment ${commentId} is not a review comment on this PR. Take the id from an ` +
995
+ `[PR review comment rc-<id> …] message in this conversation; do not guess ids.`, { cause: err });
996
+ }
997
+ throw err;
998
+ }
628
999
  }
629
1000
  /**
630
1001
  * Backs the log mode of `github_checks`: the tail of one CI job's log so