@geminixiang/mikan 1.0.0-beta.0 → 1.0.0-beta.10
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/CHANGELOG.md +147 -0
- package/dist/adapter.d.ts +1 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js.map +1 -1
- package/dist/adapters/discord/bot.d.ts +1 -0
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +9 -0
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/github/bot.d.ts +66 -0
- package/dist/adapters/github/bot.d.ts.map +1 -0
- package/dist/adapters/github/bot.js +581 -0
- package/dist/adapters/github/bot.js.map +1 -0
- package/dist/adapters/github/client.d.ts +76 -0
- package/dist/adapters/github/client.d.ts.map +1 -0
- package/dist/adapters/github/client.js +194 -0
- package/dist/adapters/github/client.js.map +1 -0
- package/dist/adapters/github/context.d.ts +9 -0
- package/dist/adapters/github/context.d.ts.map +1 -0
- package/dist/adapters/github/context.js +168 -0
- package/dist/adapters/github/context.js.map +1 -0
- package/dist/adapters/github/ids.d.ts +25 -0
- package/dist/adapters/github/ids.d.ts.map +1 -0
- package/dist/adapters/github/ids.js +39 -0
- package/dist/adapters/github/ids.js.map +1 -0
- package/dist/adapters/github/repo.d.ts +19 -0
- package/dist/adapters/github/repo.d.ts.map +1 -0
- package/dist/adapters/github/repo.js +58 -0
- package/dist/adapters/github/repo.js.map +1 -0
- package/dist/adapters/github/types.d.ts +111 -0
- package/dist/adapters/github/types.d.ts.map +1 -0
- package/dist/adapters/github/types.js +2 -0
- package/dist/adapters/github/types.js.map +1 -0
- package/dist/adapters/slack/bot.d.ts +1 -0
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +18 -1
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/slack/response-lifecycle.d.ts.map +1 -1
- package/dist/adapters/slack/response-lifecycle.js +6 -0
- package/dist/adapters/slack/response-lifecycle.js.map +1 -1
- package/dist/adapters/telegram/bot.d.ts +1 -0
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +10 -0
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/agent.d.ts +2 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +33 -11
- package/dist/agent.js.map +1 -1
- package/dist/cli/ext-git.d.ts +22 -0
- package/dist/cli/ext-git.d.ts.map +1 -0
- package/dist/cli/ext-git.js +76 -0
- package/dist/cli/ext-git.js.map +1 -0
- package/dist/cli/ext.d.ts +2 -0
- package/dist/cli/ext.d.ts.map +1 -0
- package/dist/cli/ext.js +217 -0
- package/dist/cli/ext.js.map +1 -0
- package/dist/commands/extensions.d.ts +14 -0
- package/dist/commands/extensions.d.ts.map +1 -0
- package/dist/commands/extensions.js +55 -0
- package/dist/commands/extensions.js.map +1 -0
- package/dist/commands/registry.d.ts.map +1 -1
- package/dist/commands/registry.js +2 -0
- package/dist/commands/registry.js.map +1 -1
- package/dist/execution-resolver.d.ts.map +1 -1
- package/dist/execution-resolver.js +11 -2
- package/dist/execution-resolver.js.map +1 -1
- package/dist/harness/extensions/loader.d.ts +52 -9
- package/dist/harness/extensions/loader.d.ts.map +1 -1
- package/dist/harness/extensions/loader.js +246 -46
- package/dist/harness/extensions/loader.js.map +1 -1
- package/dist/harness/extensions/types.d.ts +27 -4
- package/dist/harness/extensions/types.d.ts.map +1 -1
- package/dist/harness/extensions/types.js.map +1 -1
- package/dist/harness/index.d.ts +1 -1
- package/dist/harness/index.d.ts.map +1 -1
- package/dist/harness/index.js +1 -1
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/models.d.ts.map +1 -1
- package/dist/harness/models.js +1 -1
- package/dist/harness/models.js.map +1 -1
- package/dist/harness/runner.d.ts.map +1 -1
- package/dist/harness/runner.js +5 -1
- package/dist/harness/runner.js.map +1 -1
- package/dist/main.js +79 -8
- package/dist/main.js.map +1 -1
- package/dist/runtime/conversation-runtime.js +1 -1
- package/dist/runtime/conversation-runtime.js.map +1 -1
- package/dist/runtime/types.d.ts +5 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js.map +1 -1
- package/dist/sessions/policy.d.ts.map +1 -1
- package/dist/sessions/policy.js +1 -0
- package/dist/sessions/policy.js.map +1 -1
- package/dist/tools/github-pr.d.ts +22 -0
- package/dist/tools/github-pr.d.ts.map +1 -0
- package/dist/tools/github-pr.js +53 -0
- package/dist/tools/github-pr.js.map +1 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +8 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/react.d.ts +15 -0
- package/dist/tools/react.d.ts.map +1 -0
- package/dist/tools/react.js +40 -0
- package/dist/tools/react.js.map +1 -0
- package/dist/types.d.ts +37 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import * as log from "../../log.js";
|
|
5
|
+
import { ensureDirExists, readJsonSchemaFileIfExists } from "../../utils/file-guards.js";
|
|
6
|
+
import { atomicWritePrivateFile } from "../../utils/fs-atomic.js";
|
|
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";
|
|
10
|
+
import { processMessageIntake } from "../intake.js";
|
|
11
|
+
import { GithubClient, githubIsRateLimited } from "./client.js";
|
|
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";
|
|
15
|
+
const SyncStateSchema = Type.Object({
|
|
16
|
+
repos: Type.Record(Type.String(), Type.Object({
|
|
17
|
+
baseline: Type.String(),
|
|
18
|
+
cursor: Type.String(),
|
|
19
|
+
seenComments: Type.Array(Type.Number()),
|
|
20
|
+
seenIssues: Type.Array(Type.Number()),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Re-fetch this far behind the cursor each poll. Dedup is by id (the
|
|
25
|
+
* watermark), so the overlap only costs bandwidth and closes the boundary
|
|
26
|
+
* races of a bare `since` timestamp (DESIGN.md § Event source).
|
|
27
|
+
*/
|
|
28
|
+
const POLL_OVERLAP_MS = 5 * 60 * 1000;
|
|
29
|
+
const MAX_SEEN_IDS = 5000;
|
|
30
|
+
/** Effective role → rank, for the trigger-permission gate. */
|
|
31
|
+
const PERMISSION_RANK = {
|
|
32
|
+
none: 0,
|
|
33
|
+
read: 1,
|
|
34
|
+
triage: 2,
|
|
35
|
+
write: 3,
|
|
36
|
+
maintain: 4,
|
|
37
|
+
admin: 5,
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Commenters need write or better to trigger the agent. Fixed, not a config
|
|
41
|
+
* knob: it guards public repos (anyone can comment there), and the people who
|
|
42
|
+
* ask an agent to change code are exactly the people with push access.
|
|
43
|
+
*/
|
|
44
|
+
const REQUIRED_TRIGGER_RANK = PERMISSION_RANK.write;
|
|
45
|
+
/** Permission lookups are cached this long per repo+user. */
|
|
46
|
+
const PERMISSION_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
47
|
+
const githubRetry = (fn) => withRetry(fn, { isRateLimited: githubIsRateLimited });
|
|
48
|
+
/** GitHub comment bodies cap at 65536 chars; leave headroom for markup. */
|
|
49
|
+
export const GITHUB_MAX_COMMENT_LENGTH = 60000;
|
|
50
|
+
export const formatGithubContinuation = (partNum) => `*(continued ${partNum})*`;
|
|
51
|
+
/** Slack-style emoji short names → GitHub reaction content values. */
|
|
52
|
+
const GITHUB_REACTIONS = {
|
|
53
|
+
"+1": "+1",
|
|
54
|
+
thumbsup: "+1",
|
|
55
|
+
"-1": "-1",
|
|
56
|
+
thumbsdown: "-1",
|
|
57
|
+
laugh: "laugh",
|
|
58
|
+
smile: "laugh",
|
|
59
|
+
confused: "confused",
|
|
60
|
+
heart: "heart",
|
|
61
|
+
hooray: "hooray",
|
|
62
|
+
tada: "hooray",
|
|
63
|
+
rocket: "rocket",
|
|
64
|
+
eyes: "eyes",
|
|
65
|
+
};
|
|
66
|
+
// Repo refs are lowercased at every entry point (env config here, the
|
|
67
|
+
// installation API in start()) so conversation ids and sync-state keys never
|
|
68
|
+
// depend on how a repo's name was spelled — GitHub is case-insensitive.
|
|
69
|
+
function parseRepoList(repos) {
|
|
70
|
+
return repos.map((entry) => {
|
|
71
|
+
const [owner, repo, ...rest] = entry.split("/");
|
|
72
|
+
if (!owner || !repo || rest.length > 0) {
|
|
73
|
+
throw new Error(`Invalid GITHUB_REPOS entry (expected owner/repo): ${entry}`);
|
|
74
|
+
}
|
|
75
|
+
return { owner: owner.toLowerCase(), repo: repo.toLowerCase() };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Sets iterate in insertion order; drop the oldest ids once over the cap. */
|
|
79
|
+
function pruneSeen(seen) {
|
|
80
|
+
if (seen.size <= MAX_SEEN_IDS)
|
|
81
|
+
return;
|
|
82
|
+
let toDrop = seen.size - MAX_SEEN_IDS / 2;
|
|
83
|
+
for (const id of seen) {
|
|
84
|
+
if (toDrop-- <= 0)
|
|
85
|
+
break;
|
|
86
|
+
seen.delete(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Issue number from a comment's `issue_url` (…/repos/o/r/issues/42). */
|
|
90
|
+
function issueNumberFromUrl(issueUrl) {
|
|
91
|
+
const match = /\/issues\/(\d+)$/.exec(issueUrl);
|
|
92
|
+
if (!match) {
|
|
93
|
+
throw new Error(`Cannot parse issue number from ${issueUrl}`);
|
|
94
|
+
}
|
|
95
|
+
return Number(match[1]);
|
|
96
|
+
}
|
|
97
|
+
export class GithubMessagingBot {
|
|
98
|
+
constructor(handler, config, client) {
|
|
99
|
+
this.appSlug = null;
|
|
100
|
+
this.botEmail = null;
|
|
101
|
+
this.watchedRepos = [];
|
|
102
|
+
this.queues = new Map();
|
|
103
|
+
this.repoState = new Map();
|
|
104
|
+
this.permissionCache = new Map();
|
|
105
|
+
this.pollInFlight = false;
|
|
106
|
+
this.handler = handler;
|
|
107
|
+
this.config = config;
|
|
108
|
+
this.client =
|
|
109
|
+
client ??
|
|
110
|
+
new GithubClient({
|
|
111
|
+
appId: config.appId,
|
|
112
|
+
privateKey: config.privateKey,
|
|
113
|
+
installationId: config.installationId,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// ==========================================================================
|
|
117
|
+
// Public API (implements MessagingBot)
|
|
118
|
+
// ==========================================================================
|
|
119
|
+
async start() {
|
|
120
|
+
this.appSlug = await this.client.getAppSlug();
|
|
121
|
+
// Canonical noreply author email (<user-id>+<login>@users.noreply.github.com)
|
|
122
|
+
// so the bot's commits link to its avatar/profile; degrade to a plain
|
|
123
|
+
// noreply spelling when the lookup fails.
|
|
124
|
+
try {
|
|
125
|
+
const botUserId = await this.client.getUserId(`${this.appSlug}[bot]`);
|
|
126
|
+
this.botEmail = `${botUserId}+${this.appSlug}[bot]@users.noreply.github.com`;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
this.botEmail = `${this.appSlug}[bot]@users.noreply.github.com`;
|
|
130
|
+
}
|
|
131
|
+
this.watchedRepos =
|
|
132
|
+
this.config.repos.length > 0
|
|
133
|
+
? parseRepoList(this.config.repos)
|
|
134
|
+
: (await this.client.listInstallationRepositories()).map((repository) => ({
|
|
135
|
+
owner: repository.owner.login.toLowerCase(),
|
|
136
|
+
repo: repository.name.toLowerCase(),
|
|
137
|
+
}));
|
|
138
|
+
if (this.watchedRepos.length === 0) {
|
|
139
|
+
log.logWarning("GitHub: installation has no repositories; nothing to poll");
|
|
140
|
+
}
|
|
141
|
+
// Restore persisted watermarks; a repo watched for the first time gets a
|
|
142
|
+
// baseline of "now" so history never triggers (DESIGN.md § Event source).
|
|
143
|
+
const persisted = this.loadSyncState();
|
|
144
|
+
const now = new Date().toISOString();
|
|
145
|
+
for (const repo of this.watchedRepos) {
|
|
146
|
+
const repoKey = `${repo.owner}/${repo.repo}`;
|
|
147
|
+
const saved = persisted?.repos[repoKey];
|
|
148
|
+
this.repoState.set(repoKey, saved
|
|
149
|
+
? {
|
|
150
|
+
baseline: saved.baseline,
|
|
151
|
+
cursor: saved.cursor,
|
|
152
|
+
seenComments: new Set(saved.seenComments),
|
|
153
|
+
seenIssues: new Set(saved.seenIssues),
|
|
154
|
+
}
|
|
155
|
+
: { baseline: now, cursor: now, seenComments: new Set(), seenIssues: new Set() });
|
|
156
|
+
}
|
|
157
|
+
this.persistSyncState();
|
|
158
|
+
setInterval(() => {
|
|
159
|
+
void this.poll();
|
|
160
|
+
}, this.config.pollIntervalMs).unref();
|
|
161
|
+
log.logConnected("GitHub");
|
|
162
|
+
log.logInfo(`GitHub bot started as @${this.appSlug}[bot], polling ${this.watchedRepos.length} repo(s) every ${Math.round(this.config.pollIntervalMs / 1000)}s`);
|
|
163
|
+
}
|
|
164
|
+
async postMessage(channel, text) {
|
|
165
|
+
const ref = parseGithubConversationId(channel);
|
|
166
|
+
const parts = splitText(text, GITHUB_MAX_COMMENT_LENGTH, formatGithubContinuation);
|
|
167
|
+
const firstId = await this.postComment(ref, parts[0]);
|
|
168
|
+
for (const part of parts.slice(1)) {
|
|
169
|
+
await this.postComment(ref, part);
|
|
170
|
+
}
|
|
171
|
+
return String(firstId);
|
|
172
|
+
}
|
|
173
|
+
async updateMessage(channel, ts, text) {
|
|
174
|
+
const ref = parseGithubConversationId(channel);
|
|
175
|
+
await githubRetry(() => this.client.updateIssueComment(ref.owner, ref.repo, Number(ts), text));
|
|
176
|
+
}
|
|
177
|
+
async addReaction(channel, messageTs, emoji) {
|
|
178
|
+
const ref = parseGithubConversationId(channel);
|
|
179
|
+
const content = GITHUB_REACTIONS[emoji.replace(/^:|:$/g, "")];
|
|
180
|
+
if (!content) {
|
|
181
|
+
throw new Error(`GitHub does not support reaction '${emoji}' (supported: ${Object.keys(GITHUB_REACTIONS).join(", ")})`);
|
|
182
|
+
}
|
|
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));
|
|
186
|
+
}
|
|
187
|
+
enqueueEvent(event) {
|
|
188
|
+
const conversationId = event.conversationId;
|
|
189
|
+
const queue = this.getQueue(conversationId);
|
|
190
|
+
if (queue.size() >= 5) {
|
|
191
|
+
log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
|
|
195
|
+
queue.enqueue(() => {
|
|
196
|
+
const context = createGithubAdapters(event, this);
|
|
197
|
+
return this.handler.handleEvent(event, this, context);
|
|
198
|
+
});
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
getMessagingInfo() {
|
|
202
|
+
return {
|
|
203
|
+
name: "github",
|
|
204
|
+
formattingGuide: "## GitHub Formatting (GitHub Flavored Markdown)\n" +
|
|
205
|
+
"Standard Markdown plus tables, task lists, and ```suggestion blocks.\n" +
|
|
206
|
+
"Reference issues/PRs as #123 and users as @login.",
|
|
207
|
+
channels: [],
|
|
208
|
+
users: [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// ==========================================================================
|
|
212
|
+
// Internal helpers (used by context.ts)
|
|
213
|
+
// ==========================================================================
|
|
214
|
+
async postComment(ref, text) {
|
|
215
|
+
const comment = await githubRetry(() => this.client.createIssueComment(ref.owner, ref.repo, ref.number, text));
|
|
216
|
+
return comment.id;
|
|
217
|
+
}
|
|
218
|
+
async deleteComment(ref, commentId) {
|
|
219
|
+
await githubRetry(() => this.client.deleteIssueComment(ref.owner, ref.repo, commentId));
|
|
220
|
+
}
|
|
221
|
+
logToFile(conversationId, entry) {
|
|
222
|
+
appendChannelLog(this.config.workingDir, conversationId, entry);
|
|
223
|
+
}
|
|
224
|
+
logBotResponse(conversationId, text, ts) {
|
|
225
|
+
appendBotResponseLog(this.config.workingDir, conversationId, text, ts);
|
|
226
|
+
}
|
|
227
|
+
// ==========================================================================
|
|
228
|
+
// Private - polling
|
|
229
|
+
// ==========================================================================
|
|
230
|
+
getQueue(conversationId) {
|
|
231
|
+
let queue = this.queues.get(conversationId);
|
|
232
|
+
if (!queue) {
|
|
233
|
+
queue = new MessagingEventQueue("GitHub");
|
|
234
|
+
this.queues.set(conversationId, queue);
|
|
235
|
+
}
|
|
236
|
+
return queue;
|
|
237
|
+
}
|
|
238
|
+
/** One poll tick over every watched repo. Public for tests. */
|
|
239
|
+
async poll() {
|
|
240
|
+
if (this.pollInFlight)
|
|
241
|
+
return;
|
|
242
|
+
this.pollInFlight = true;
|
|
243
|
+
try {
|
|
244
|
+
let changed = false;
|
|
245
|
+
for (const repo of this.watchedRepos) {
|
|
246
|
+
try {
|
|
247
|
+
changed = (await this.pollRepo(repo)) || changed;
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
log.logWarning(`GitHub poll failed for ${repo.owner}/${repo.repo}`, err instanceof Error ? err.message : String(err));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (changed) {
|
|
254
|
+
this.persistSyncState();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
this.pollInFlight = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async pollRepo(repo) {
|
|
262
|
+
const repoKey = `${repo.owner}/${repo.repo}`;
|
|
263
|
+
const state = this.repoState.get(repoKey);
|
|
264
|
+
if (!state)
|
|
265
|
+
return false;
|
|
266
|
+
const since = new Date(Date.parse(state.cursor) - POLL_OVERLAP_MS).toISOString();
|
|
267
|
+
let changed = false;
|
|
268
|
+
// Comment activity also bumps the parent issue's updated_at, so the issues
|
|
269
|
+
// list mostly echoes the comments list; only issues *created* since the
|
|
270
|
+
// baseline are new conversations (their body is the first message).
|
|
271
|
+
const issues = (await this.client.listIssuesSince(repo.owner, repo.repo, since)) ?? [];
|
|
272
|
+
for (const issue of issues) {
|
|
273
|
+
if (issue.updated_at > state.cursor) {
|
|
274
|
+
state.cursor = issue.updated_at;
|
|
275
|
+
changed = true;
|
|
276
|
+
}
|
|
277
|
+
// Edits re-surface old items with a fresh updated_at; the id watermark
|
|
278
|
+
// plus the creation baseline decide what actually triggers.
|
|
279
|
+
if (issue.created_at < state.baseline || state.seenIssues.has(issue.id))
|
|
280
|
+
continue;
|
|
281
|
+
state.seenIssues.add(issue.id);
|
|
282
|
+
changed = true;
|
|
283
|
+
if (issue.user.type === "Bot")
|
|
284
|
+
continue;
|
|
285
|
+
await this.handleIncoming({
|
|
286
|
+
ref: { ...repo, number: issue.number },
|
|
287
|
+
ts: GITHUB_ISSUE_BODY_TS,
|
|
288
|
+
user: issue.user.login,
|
|
289
|
+
text: `# ${issue.title}\n\n${issue.body ?? ""}`.trim(),
|
|
290
|
+
createdAt: issue.created_at,
|
|
291
|
+
isPr: Boolean(issue.pull_request),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const comments = (await this.client.listIssueCommentsSince(repo.owner, repo.repo, since)) ?? [];
|
|
295
|
+
for (const comment of comments) {
|
|
296
|
+
if (comment.updated_at > state.cursor) {
|
|
297
|
+
state.cursor = comment.updated_at;
|
|
298
|
+
changed = true;
|
|
299
|
+
}
|
|
300
|
+
if (comment.created_at < state.baseline || state.seenComments.has(comment.id))
|
|
301
|
+
continue;
|
|
302
|
+
state.seenComments.add(comment.id);
|
|
303
|
+
changed = true;
|
|
304
|
+
if (comment.user.type === "Bot")
|
|
305
|
+
continue;
|
|
306
|
+
await this.handleIncoming({
|
|
307
|
+
ref: { ...repo, number: issueNumberFromUrl(comment.issue_url) },
|
|
308
|
+
ts: String(comment.id),
|
|
309
|
+
user: comment.user.login,
|
|
310
|
+
text: comment.body,
|
|
311
|
+
createdAt: comment.created_at,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
pruneSeen(state.seenComments);
|
|
315
|
+
pruneSeen(state.seenIssues);
|
|
316
|
+
return changed;
|
|
317
|
+
}
|
|
318
|
+
// ==========================================================================
|
|
319
|
+
// Private - watermark persistence
|
|
320
|
+
// ==========================================================================
|
|
321
|
+
loadSyncState() {
|
|
322
|
+
try {
|
|
323
|
+
return readJsonSchemaFileIfExists(this.config.syncStatePath, SyncStateSchema, (detail) => `Malformed GitHub sync state at ${this.config.syncStatePath}: ${detail}`);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
// A bad state file re-baselines (history won't trigger) rather than
|
|
327
|
+
// blocking startup; the next persist overwrites it.
|
|
328
|
+
log.logWarning("GitHub: ignoring unreadable sync state", err instanceof Error ? err.message : String(err));
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
persistSyncState() {
|
|
333
|
+
const state = { repos: {} };
|
|
334
|
+
for (const [repoKey, watermark] of this.repoState) {
|
|
335
|
+
state.repos[repoKey] = {
|
|
336
|
+
baseline: watermark.baseline,
|
|
337
|
+
cursor: watermark.cursor,
|
|
338
|
+
seenComments: [...watermark.seenComments],
|
|
339
|
+
seenIssues: [...watermark.seenIssues],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
ensureDirExists(dirname(this.config.syncStatePath));
|
|
343
|
+
atomicWritePrivateFile(this.config.syncStatePath, JSON.stringify(state));
|
|
344
|
+
}
|
|
345
|
+
// ==========================================================================
|
|
346
|
+
// Private - triggering
|
|
347
|
+
// ==========================================================================
|
|
348
|
+
/** Fresh regex per call: the `g` flag makes `test`/`replace` stateful. */
|
|
349
|
+
mentionPattern() {
|
|
350
|
+
return this.appSlug ? new RegExp(`@${this.appSlug}(?![\\w-])`, "gi") : null;
|
|
351
|
+
}
|
|
352
|
+
isMentioned(text) {
|
|
353
|
+
const pattern = this.mentionPattern();
|
|
354
|
+
return pattern ? pattern.test(text) : false;
|
|
355
|
+
}
|
|
356
|
+
stripMention(text) {
|
|
357
|
+
const pattern = this.mentionPattern();
|
|
358
|
+
return (pattern ? text.replace(pattern, "") : text).trim();
|
|
359
|
+
}
|
|
360
|
+
isParticipating(conversationId) {
|
|
361
|
+
return existsSync(join(this.config.workingDir, conversationId, "log.jsonl"));
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* True when `user` holds at least write permission on the repo. Fails
|
|
365
|
+
* closed: a failed lookup denies the trigger (and logs) rather than
|
|
366
|
+
* letting an unverified commenter drive the agent.
|
|
367
|
+
*/
|
|
368
|
+
async hasTriggerPermission(ref, user) {
|
|
369
|
+
const cacheKey = `${ref.owner}/${ref.repo}#${user}`;
|
|
370
|
+
const cached = this.permissionCache.get(cacheKey);
|
|
371
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
372
|
+
return cached.rank >= REQUIRED_TRIGGER_RANK;
|
|
373
|
+
}
|
|
374
|
+
let rank;
|
|
375
|
+
try {
|
|
376
|
+
const role = await githubRetry(() => this.client.getCollaboratorPermission(ref.owner, ref.repo, user));
|
|
377
|
+
// Custom role_name values are unrankable; the legacy permission field
|
|
378
|
+
// still carries their closest standard mapping, so take the stronger.
|
|
379
|
+
rank = Math.max(PERMISSION_RANK[role.role_name ?? ""] ?? 0, PERMISSION_RANK[role.permission] ?? 0);
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
log.logWarning(`GitHub: permission lookup failed for ${user} on ${ref.owner}/${ref.repo}; denying trigger`, err instanceof Error ? err.message : String(err));
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
this.permissionCache.set(cacheKey, {
|
|
386
|
+
rank,
|
|
387
|
+
expiresAt: Date.now() + PERMISSION_CACHE_TTL_MS,
|
|
388
|
+
});
|
|
389
|
+
return rank >= REQUIRED_TRIGGER_RANK;
|
|
390
|
+
}
|
|
391
|
+
async handleIncoming(item) {
|
|
392
|
+
const conversationId = buildGithubConversationId(item.ref);
|
|
393
|
+
const mentioned = this.isMentioned(item.text);
|
|
394
|
+
const participating = this.isParticipating(conversationId);
|
|
395
|
+
// Narrow trigger (DESIGN.md): only threads the bot is mentioned in or
|
|
396
|
+
// already participates in. Everything else is ignored without logging so
|
|
397
|
+
// busy repos don't grow conversation dirs for untouched issues.
|
|
398
|
+
if (!mentioned && !participating)
|
|
399
|
+
return;
|
|
400
|
+
// Permission gate: on public repos anyone can comment, so a mention from
|
|
401
|
+
// anyone below write permission is ignored entirely (no log, no state).
|
|
402
|
+
if (!(await this.hasTriggerPermission(item.ref, item.user))) {
|
|
403
|
+
log.logInfo(`GitHub: ignoring ${conversationId} comment from ${item.user} (below write permission)`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const cleanedText = this.stripMention(item.text);
|
|
407
|
+
const sessionKey = resolveChatSessionKey({
|
|
408
|
+
conversationId,
|
|
409
|
+
conversationKind: "shared",
|
|
410
|
+
messageId: item.ts,
|
|
411
|
+
persistentTopLevel: true,
|
|
412
|
+
});
|
|
413
|
+
if (/^stop$/i.test(cleanedText)) {
|
|
414
|
+
this.logToFile(conversationId, {
|
|
415
|
+
date: item.createdAt,
|
|
416
|
+
ts: item.ts,
|
|
417
|
+
user: item.user,
|
|
418
|
+
userName: item.user,
|
|
419
|
+
text: cleanedText,
|
|
420
|
+
attachments: [],
|
|
421
|
+
isMessagingBot: false,
|
|
422
|
+
});
|
|
423
|
+
const stopTarget = resolveStopTarget({ handler: this.handler, conversationId, sessionKey });
|
|
424
|
+
if (stopTarget) {
|
|
425
|
+
await this.handler.handleStop(stopTarget, conversationId, this);
|
|
426
|
+
}
|
|
427
|
+
else if (mentioned) {
|
|
428
|
+
await this.postMessage(conversationId, formatNothingRunning("github"));
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// First contact: log the issue title/body ahead of the comment so the
|
|
433
|
+
// session history starts with what the thread is about. The clone is
|
|
434
|
+
// (re)attempted on every trigger while ./repo is missing — a no-op once
|
|
435
|
+
// it exists — so a failed first clone (e.g. App permissions granted
|
|
436
|
+
// later) heals on the next mention instead of wedging the conversation.
|
|
437
|
+
let isPrHint = item.isPr;
|
|
438
|
+
if (!participating && item.ts !== GITHUB_ISSUE_BODY_TS) {
|
|
439
|
+
const issue = await this.logIssueContext(item.ref, conversationId, item.createdAt);
|
|
440
|
+
if (issue)
|
|
441
|
+
isPrHint = Boolean(issue.pull_request);
|
|
442
|
+
}
|
|
443
|
+
if (!existsSync(this.repoDir(conversationId))) {
|
|
444
|
+
const isPr = isPrHint ?? (await this.fetchIsPr(item.ref));
|
|
445
|
+
await this.ensureRepoClone(item.ref, conversationId, isPr);
|
|
446
|
+
}
|
|
447
|
+
const eventBase = {
|
|
448
|
+
type: item.ts === GITHUB_ISSUE_BODY_TS ? "issue" : "message",
|
|
449
|
+
conversationId,
|
|
450
|
+
conversationKind: "shared",
|
|
451
|
+
ts: item.ts,
|
|
452
|
+
sessionKey,
|
|
453
|
+
user: item.user,
|
|
454
|
+
userName: item.user,
|
|
455
|
+
text: cleanedText,
|
|
456
|
+
};
|
|
457
|
+
await processMessageIntake({
|
|
458
|
+
eventBase,
|
|
459
|
+
workingDir: this.config.workingDir,
|
|
460
|
+
isAutoReplyCandidate: false,
|
|
461
|
+
logEntryBase: {
|
|
462
|
+
date: item.createdAt,
|
|
463
|
+
ts: item.ts,
|
|
464
|
+
user: item.user,
|
|
465
|
+
userName: item.user,
|
|
466
|
+
text: cleanedText,
|
|
467
|
+
isMessagingBot: false,
|
|
468
|
+
},
|
|
469
|
+
log: (entry) => this.logToFile(conversationId, entry),
|
|
470
|
+
processAttachments: async () => [],
|
|
471
|
+
queueKey: conversationId,
|
|
472
|
+
enqueue: (queueKey, work) => this.getQueue(queueKey).enqueue(work),
|
|
473
|
+
handler: this.handler,
|
|
474
|
+
bot: this,
|
|
475
|
+
createContext: (event) => createGithubAdapters(event, this),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
async logIssueContext(ref, conversationId, triggerCreatedAt) {
|
|
479
|
+
let issue;
|
|
480
|
+
try {
|
|
481
|
+
issue = await githubRetry(() => this.client.getIssue(ref.owner, ref.repo, ref.number));
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
log.logWarning(`GitHub: could not fetch issue context for ${conversationId}`, err instanceof Error ? err.message : String(err));
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
// Dated just before the triggering comment, NOT issue.created_at: history
|
|
488
|
+
// sync date-sorts entries and drops anything older than its recency
|
|
489
|
+
// window, so an old issue's real creation date would silently evict the
|
|
490
|
+
// one message that says what the thread is about.
|
|
491
|
+
const triggerMs = Date.parse(triggerCreatedAt);
|
|
492
|
+
const contextDate = new Date((Number.isFinite(triggerMs) ? triggerMs : Date.now()) - 1000);
|
|
493
|
+
this.logToFile(conversationId, {
|
|
494
|
+
date: contextDate.toISOString(),
|
|
495
|
+
ts: GITHUB_ISSUE_BODY_TS,
|
|
496
|
+
user: issue.user.login,
|
|
497
|
+
userName: issue.user.login,
|
|
498
|
+
text: `# ${issue.title}\n\n${issue.body ?? ""}`.trim(),
|
|
499
|
+
attachments: [],
|
|
500
|
+
isMessagingBot: false,
|
|
501
|
+
});
|
|
502
|
+
return issue;
|
|
503
|
+
}
|
|
504
|
+
// ==========================================================================
|
|
505
|
+
// Repo clone + pull requests
|
|
506
|
+
// ==========================================================================
|
|
507
|
+
repoDir(conversationId) {
|
|
508
|
+
return join(this.config.workingDir, conversationId, "repo");
|
|
509
|
+
}
|
|
510
|
+
/** Whether the conversation's issue is a PR, for clone retries where the
|
|
511
|
+
* triggering item didn't carry that knowledge. Errs toward plain issue. */
|
|
512
|
+
async fetchIsPr(ref) {
|
|
513
|
+
try {
|
|
514
|
+
const issue = await githubRetry(() => this.client.getIssue(ref.owner, ref.repo, ref.number));
|
|
515
|
+
return Boolean(issue.pull_request);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Clone the conversation's repo into its dir (bind-mounted into the
|
|
523
|
+
* sandbox) using an ephemeral contents:read token that never leaves the
|
|
524
|
+
* host. Non-fatal: a failed clone degrades to the pre-clone experience.
|
|
525
|
+
*/
|
|
526
|
+
async ensureRepoClone(ref, conversationId, isPr) {
|
|
527
|
+
const dir = this.repoDir(conversationId);
|
|
528
|
+
if (existsSync(dir))
|
|
529
|
+
return;
|
|
530
|
+
try {
|
|
531
|
+
const token = await this.client.createScopedInstallationToken(ref.repo, {
|
|
532
|
+
contents: "read",
|
|
533
|
+
});
|
|
534
|
+
await cloneRepo({
|
|
535
|
+
url: `https://github.com/${ref.owner}/${ref.repo}.git`,
|
|
536
|
+
dir,
|
|
537
|
+
token,
|
|
538
|
+
botLogin: `${this.appSlug}[bot]`,
|
|
539
|
+
botEmail: this.botEmail ?? `${this.appSlug}[bot]@users.noreply.github.com`,
|
|
540
|
+
prNumber: isPr ? ref.number : undefined,
|
|
541
|
+
});
|
|
542
|
+
log.logInfo(`[${conversationId}] Cloned ${ref.owner}/${ref.repo}${isPr ? ` and checked out PR #${ref.number} head` : ""}`);
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
log.logWarning(`GitHub: repo clone failed for ${conversationId}`, err instanceof Error ? err.message : String(err));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Backs the `github_pr` tool: push a `pi/*` branch the agent prepared in
|
|
550
|
+
* the conversation's clone, then open a pull request as the App. The write
|
|
551
|
+
* token is minted per call, scoped to this one repo, and never enters the
|
|
552
|
+
* sandbox.
|
|
553
|
+
*/
|
|
554
|
+
async pushAndCreatePr(conversationId, request) {
|
|
555
|
+
const ref = parseGithubConversationId(conversationId);
|
|
556
|
+
const dir = this.repoDir(conversationId);
|
|
557
|
+
if (!existsSync(dir)) {
|
|
558
|
+
throw new Error("This conversation has no ./repo clone to push from.");
|
|
559
|
+
}
|
|
560
|
+
if (!GITHUB_PUSH_BRANCH_PATTERN.test(request.branch)) {
|
|
561
|
+
throw new Error(`Branch '${request.branch}' is not pushable: name it pi/<something> (e.g. pi/fix-${ref.number}).`);
|
|
562
|
+
}
|
|
563
|
+
const repository = await githubRetry(() => this.client.getRepository(ref.owner, ref.repo));
|
|
564
|
+
const base = request.base ?? repository.default_branch;
|
|
565
|
+
const token = await this.client.createScopedInstallationToken(ref.repo, {
|
|
566
|
+
contents: "write",
|
|
567
|
+
pull_requests: "write",
|
|
568
|
+
});
|
|
569
|
+
await pushBranch({ dir, branch: request.branch, token });
|
|
570
|
+
const pr = await githubRetry(() => this.client.createPullRequest(ref.owner, ref.repo, {
|
|
571
|
+
title: request.title,
|
|
572
|
+
head: request.branch,
|
|
573
|
+
base,
|
|
574
|
+
body: request.body,
|
|
575
|
+
draft: request.draft,
|
|
576
|
+
}));
|
|
577
|
+
log.logInfo(`[${conversationId}] Opened PR #${pr.number}: ${pr.html_url}`);
|
|
578
|
+
return { number: pr.number, url: pr.html_url };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
//# sourceMappingURL=bot.js.map
|