wolli 0.0.3 → 0.0.5
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/README.md +23 -24
- package/built-in/plugins/discord/discord-chat.ts +47 -84
- package/built-in/plugins/discord/index.ts +139 -97
- package/built-in/plugins/discord/package.json +2 -2
- package/built-in/plugins/github/README.md +148 -0
- package/built-in/plugins/github/github-api.test.ts +260 -0
- package/built-in/plugins/github/github-api.ts +491 -0
- package/built-in/plugins/github/github-chat.ts +216 -0
- package/built-in/plugins/github/github-review.ts +102 -0
- package/built-in/plugins/github/github-workspace.ts +84 -0
- package/built-in/plugins/github/index.ts +795 -0
- package/built-in/plugins/github/package.json +14 -0
- package/built-in/plugins/scheduler/cron.ts +131 -0
- package/built-in/plugins/scheduler/index.ts +147 -151
- package/built-in/plugins/scheduler/package.json +3 -2
- package/built-in/plugins/scheduler/scheduler-due.ts +22 -0
- package/built-in/plugins/telegram/README.md +3 -3
- package/built-in/plugins/telegram/index.ts +177 -139
- package/built-in/plugins/telegram/package.json +2 -2
- package/built-in/plugins/telegram/telegram-chat.ts +79 -155
- package/dist/cli.js +6208 -6355
- package/docs/hooks.md +103 -0
- package/docs/index.md +10 -8
- package/docs/integrations.md +78 -663
- package/docs/introduction.md +95 -0
- package/docs/plugins.md +43 -36
- package/docs/providers.md +63 -0
- package/docs/sdk.md +42 -47
- package/docs/tools.md +81 -0
- package/docs/workflows.md +170 -0
- package/package.json +1 -1
- package/built-in/plugins/scheduler/scheduler-chat.ts +0 -164
- package/docs/extensions.md +0 -2331
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub integration — the transport half (self-contained package).
|
|
3
|
+
*
|
|
4
|
+
* wolli has no inbound webhook surface, so this integration is a **polling** producer in the
|
|
5
|
+
* same shape as the telegram long-poll / scheduler tick: `run()` wakes on a timer, pulls each
|
|
6
|
+
* watched repo's cheap repo-level "since" endpoints with a conditional (ETag) request, and
|
|
7
|
+
* emits one generic event per new item. It holds the GitHub App credentials and mints
|
|
8
|
+
* per-installation tokens; it never touches sessions. The routing workflows (`github-chat.ts`,
|
|
9
|
+
* declared under `wolli.workflows`) map those events onto sessions.
|
|
10
|
+
*
|
|
11
|
+
* Auth is a GitHub App: a short-lived RS256 JWT is exchanged per installation for a ~1h token.
|
|
12
|
+
* No webhook secret, no public URL. The standard App-auth mechanics, the REST wrapper, the raw
|
|
13
|
+
* normalizers, and the pure cursor logic all live in `./github-api.ts` (host-free, so they unit
|
|
14
|
+
* test on their own); this file is the `defineIntegration(...)` transport that wires them to
|
|
15
|
+
* `ctx.account` / `ctx.store` / `ctx.emit`.
|
|
16
|
+
*
|
|
17
|
+
* ## Install + configure
|
|
18
|
+
*
|
|
19
|
+
* wolli <agent> plugins install ./built-in/plugins/github
|
|
20
|
+
* # then paste the App id + private key, enter the repos, pick the triggers.
|
|
21
|
+
*
|
|
22
|
+
* See README.md for creating the App and the end-to-end setup.
|
|
23
|
+
*
|
|
24
|
+
* ## Known v1 limitations
|
|
25
|
+
* - Polling, not webhooks: latency is up to one poll interval, and the `issues` / `pull_request`
|
|
26
|
+
* streams carry a BEST-EFFORT `action` (opened/edited/closed/synchronize) synthesized from
|
|
27
|
+
* state + timestamps — there is no real webhook `action` to read.
|
|
28
|
+
* - The excluded tiers (`pull_request_review`, `push`) have no clean repo-level "since" cursor.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { defineIntegration, type IntegrationOnboardContext, type KeyValueStore } from "wolli";
|
|
32
|
+
import { Type } from "typebox";
|
|
33
|
+
import {
|
|
34
|
+
type AppCredentials,
|
|
35
|
+
createAppJwt,
|
|
36
|
+
createInstallationToken,
|
|
37
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
38
|
+
githubRequest,
|
|
39
|
+
normalizeIssue,
|
|
40
|
+
normalizeIssueComment,
|
|
41
|
+
normalizePullRequest,
|
|
42
|
+
normalizeReviewComment,
|
|
43
|
+
parseJson,
|
|
44
|
+
parseRepo,
|
|
45
|
+
type RawJson,
|
|
46
|
+
readHeadSha,
|
|
47
|
+
resolveRepoInstallationId,
|
|
48
|
+
selectPullRequests,
|
|
49
|
+
selectSinceItems,
|
|
50
|
+
} from "./github-api.ts";
|
|
51
|
+
import { checkout } from "./github-workspace.ts";
|
|
52
|
+
|
|
53
|
+
/** The four cheap repo-level streams the transport polls. */
|
|
54
|
+
type Stream = "issue_comment" | "pull_request_review_comment" | "issues" | "pull_request";
|
|
55
|
+
|
|
56
|
+
interface GithubAccount {
|
|
57
|
+
appId: string;
|
|
58
|
+
privateKey: string;
|
|
59
|
+
botLogin: string;
|
|
60
|
+
repositories: string[];
|
|
61
|
+
triggers: ("mention" | "auto")[];
|
|
62
|
+
pollIntervalMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pull the App credentials out of the resolved account record. */
|
|
66
|
+
function credentials(account: GithubAccount): AppCredentials {
|
|
67
|
+
return { appId: account.appId, privateKey: account.privateKey };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Watch-set + stream gate (the two named seams)
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the repos to watch: `account.repositories` with each repo's installation id, cached
|
|
76
|
+
* in `ctx.store` under `installation:<owner>/<repo>` (an App is installed per org/user; tokens
|
|
77
|
+
* are per-installation). A repo whose installation cannot be resolved is logged and skipped so
|
|
78
|
+
* one bad entry never stalls the loop.
|
|
79
|
+
*/
|
|
80
|
+
async function getWatchedRepos(
|
|
81
|
+
account: GithubAccount,
|
|
82
|
+
store: KeyValueStore,
|
|
83
|
+
): Promise<Array<{ owner: string; repo: string; installationId: number }>> {
|
|
84
|
+
const watched: Array<{ owner: string; repo: string; installationId: number }> = [];
|
|
85
|
+
for (const full of account.repositories) {
|
|
86
|
+
try {
|
|
87
|
+
const { owner, repo } = parseRepo(full);
|
|
88
|
+
const key = `installation:${owner}/${repo}`;
|
|
89
|
+
let installationId = store.get(key) as number | undefined;
|
|
90
|
+
if (installationId === undefined) {
|
|
91
|
+
installationId = await resolveRepoInstallationId(credentials(account), owner, repo);
|
|
92
|
+
store.set(key, installationId);
|
|
93
|
+
}
|
|
94
|
+
watched.push({ owner, repo, installationId });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[github] skipping ${full}:`, err instanceof Error ? err.message : err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return watched;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a stream is polled. v1 enables all four; kept as the named seam for future per-stream
|
|
104
|
+
* config so onboarding can stay "repos only" without a bind-a-workflow-but-forgot-the-toggle footgun.
|
|
105
|
+
*/
|
|
106
|
+
function isStreamEnabled(_stream: Stream): boolean {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Poll loop
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
/** Per-`since`-stream endpoint config. */
|
|
115
|
+
const SINCE_STREAMS: Array<{
|
|
116
|
+
stream: Stream;
|
|
117
|
+
subpath: string;
|
|
118
|
+
stateAll: boolean;
|
|
119
|
+
filter?: (raw: RawJson) => boolean;
|
|
120
|
+
normalize: (repo: string, raw: RawJson) => unknown;
|
|
121
|
+
}> = [
|
|
122
|
+
{ stream: "issue_comment", subpath: "/issues/comments", stateAll: false, normalize: normalizeIssueComment },
|
|
123
|
+
{
|
|
124
|
+
stream: "pull_request_review_comment",
|
|
125
|
+
subpath: "/pulls/comments",
|
|
126
|
+
stateAll: false,
|
|
127
|
+
normalize: normalizeReviewComment,
|
|
128
|
+
},
|
|
129
|
+
{ stream: "issues", subpath: "/issues", stateAll: true, filter: (raw) => !raw.pull_request, normalize: normalizeIssue },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Poll one `since`-based stream for one repo. Establishes a baseline cursor on the first run
|
|
134
|
+
* (emits nothing), then on later ticks advances the cursor + boundary-id set and persists them
|
|
135
|
+
* BEFORE emitting (at-most-once). Returns GitHub's `X-Poll-Interval` in ms when present.
|
|
136
|
+
*/
|
|
137
|
+
async function pollSinceStream(
|
|
138
|
+
config: (typeof SINCE_STREAMS)[number],
|
|
139
|
+
repoFull: string,
|
|
140
|
+
token: string,
|
|
141
|
+
store: KeyValueStore,
|
|
142
|
+
emit: (event: string, data: unknown) => void,
|
|
143
|
+
): Promise<number | null> {
|
|
144
|
+
const { stream, subpath, stateAll, filter, normalize } = config;
|
|
145
|
+
const cursorKey = `cursor:${repoFull}:${stream}`;
|
|
146
|
+
const seenKey = `seen:${repoFull}:${stream}`;
|
|
147
|
+
const etagKey = `etag:${repoFull}:${stream}`;
|
|
148
|
+
|
|
149
|
+
const cursor = store.get(cursorKey) as string | undefined;
|
|
150
|
+
if (cursor === undefined) {
|
|
151
|
+
// Baseline: only see items that arrive after install; no history replay.
|
|
152
|
+
store.set(cursorKey, new Date().toISOString());
|
|
153
|
+
store.set(seenKey, []);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const etag = store.get(etagKey) as string | undefined;
|
|
158
|
+
const query = `?since=${encodeURIComponent(cursor)}&sort=updated&direction=asc&per_page=100${
|
|
159
|
+
stateAll ? "&state=all" : ""
|
|
160
|
+
}`;
|
|
161
|
+
const res = await githubRequest("GET", `/repos/${repoFull}${subpath}${query}`, { token, etag });
|
|
162
|
+
if (res.status === 304) return res.pollIntervalMs;
|
|
163
|
+
|
|
164
|
+
const rawItems = (parseJson<RawJson[]>(res.text) ?? []).filter((raw) => (filter ? filter(raw) : true));
|
|
165
|
+
const items = rawItems.map((raw) => ({
|
|
166
|
+
id: typeof raw.id === "number" ? raw.id : 0,
|
|
167
|
+
ts: String(raw.updated_at),
|
|
168
|
+
raw,
|
|
169
|
+
}));
|
|
170
|
+
const seenIds = (store.get(seenKey) as number[] | undefined) ?? [];
|
|
171
|
+
const { toEmit, nextCursor, nextSeenIds } = selectSinceItems(items, cursor, seenIds);
|
|
172
|
+
|
|
173
|
+
// Persist the advanced cursor before emitting so a crash right after an emit never re-fires.
|
|
174
|
+
store.set(cursorKey, nextCursor);
|
|
175
|
+
store.set(seenKey, nextSeenIds);
|
|
176
|
+
if (res.etag) store.set(etagKey, res.etag);
|
|
177
|
+
|
|
178
|
+
for (const it of toEmit) emit(stream, normalize(repoFull, it.raw));
|
|
179
|
+
return res.pollIntervalMs;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Poll the `pull_request` stream for one repo. The pulls list has no `since`, so items are
|
|
184
|
+
* deduped by head SHA per PR number: a PR is emitted when its head advances or it is new. The
|
|
185
|
+
* first run records current heads (emits nothing). Head map is persisted before emitting.
|
|
186
|
+
*/
|
|
187
|
+
async function pollPullRequests(
|
|
188
|
+
repoFull: string,
|
|
189
|
+
token: string,
|
|
190
|
+
store: KeyValueStore,
|
|
191
|
+
emit: (event: string, data: unknown) => void,
|
|
192
|
+
): Promise<number | null> {
|
|
193
|
+
const etagKey = `etag:${repoFull}:pull_request`;
|
|
194
|
+
const headsKey = `prHeads:${repoFull}`;
|
|
195
|
+
const etag = store.get(etagKey) as string | undefined;
|
|
196
|
+
const res = await githubRequest(
|
|
197
|
+
"GET",
|
|
198
|
+
`/repos/${repoFull}/pulls?state=all&sort=updated&direction=desc&per_page=100`,
|
|
199
|
+
{ token, etag },
|
|
200
|
+
);
|
|
201
|
+
if (res.status === 304) return res.pollIntervalMs;
|
|
202
|
+
|
|
203
|
+
const rawItems = parseJson<RawJson[]>(res.text) ?? [];
|
|
204
|
+
const items = rawItems.map((raw) => ({
|
|
205
|
+
number: typeof raw.number === "number" ? raw.number : 0,
|
|
206
|
+
headSha: readHeadSha(raw),
|
|
207
|
+
raw,
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
const prHeads = store.get(headsKey) as Record<string, string> | undefined;
|
|
211
|
+
if (prHeads === undefined) {
|
|
212
|
+
// Baseline: record current heads without emitting.
|
|
213
|
+
const seeded: Record<string, string> = {};
|
|
214
|
+
for (const it of items) seeded[String(it.number)] = it.headSha;
|
|
215
|
+
store.set(headsKey, seeded);
|
|
216
|
+
if (res.etag) store.set(etagKey, res.etag);
|
|
217
|
+
return res.pollIntervalMs;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const { toEmit, nextPrHeads } = selectPullRequests(items, prHeads);
|
|
221
|
+
store.set(headsKey, nextPrHeads);
|
|
222
|
+
if (res.etag) store.set(etagKey, res.etag);
|
|
223
|
+
|
|
224
|
+
for (const it of toEmit) emit("pull_request", normalizePullRequest(repoFull, it.raw));
|
|
225
|
+
return res.pollIntervalMs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One poll pass over every watched repo × enabled stream. Returns the max poll floor GitHub asked for. */
|
|
229
|
+
async function tick(
|
|
230
|
+
account: GithubAccount,
|
|
231
|
+
store: KeyValueStore,
|
|
232
|
+
emit: (event: string, data: unknown) => void,
|
|
233
|
+
): Promise<number> {
|
|
234
|
+
let pollFloorMs = 0;
|
|
235
|
+
const note = (ms: number | null) => {
|
|
236
|
+
if (ms && ms > pollFloorMs) pollFloorMs = ms;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
for (const { owner, repo, installationId } of await getWatchedRepos(account, store)) {
|
|
240
|
+
const repoFull = `${owner}/${repo}`;
|
|
241
|
+
let token: string;
|
|
242
|
+
try {
|
|
243
|
+
token = await createInstallationToken(credentials(account), installationId);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error(`[github] token for ${repoFull} failed:`, err instanceof Error ? err.message : err);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const config of SINCE_STREAMS) {
|
|
250
|
+
if (!isStreamEnabled(config.stream)) continue;
|
|
251
|
+
try {
|
|
252
|
+
note(await pollSinceStream(config, repoFull, token, store, emit));
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error(`[github] poll ${config.stream} ${repoFull} failed:`, err instanceof Error ? err.message : err);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (isStreamEnabled("pull_request")) {
|
|
259
|
+
try {
|
|
260
|
+
note(await pollPullRequests(repoFull, token, store, emit));
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(`[github] poll pull_request ${repoFull} failed:`, err instanceof Error ? err.message : err);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return pollFloorMs;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// Actions
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
/** Resolve (and cache) the installation token for a repo, then issue a repo-scoped REST call. */
|
|
274
|
+
async function repoRequest(
|
|
275
|
+
account: GithubAccount,
|
|
276
|
+
store: KeyValueStore,
|
|
277
|
+
repoFull: string,
|
|
278
|
+
method: string,
|
|
279
|
+
subpath: string,
|
|
280
|
+
options: { accept?: string; body?: unknown } = {},
|
|
281
|
+
): Promise<string> {
|
|
282
|
+
const { owner, repo } = parseRepo(repoFull);
|
|
283
|
+
const key = `installation:${owner}/${repo}`;
|
|
284
|
+
let installationId = store.get(key) as number | undefined;
|
|
285
|
+
if (installationId === undefined) {
|
|
286
|
+
installationId = await resolveRepoInstallationId(credentials(account), owner, repo);
|
|
287
|
+
store.set(key, installationId);
|
|
288
|
+
}
|
|
289
|
+
const token = await createInstallationToken(credentials(account), installationId);
|
|
290
|
+
const res = await githubRequest(method, `/repos/${owner}/${repo}${subpath}`, {
|
|
291
|
+
token,
|
|
292
|
+
accept: options.accept,
|
|
293
|
+
body: options.body,
|
|
294
|
+
});
|
|
295
|
+
return res.text;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ============================================================================
|
|
299
|
+
// Onboarding
|
|
300
|
+
// ============================================================================
|
|
301
|
+
|
|
302
|
+
const ONBOARD_GUIDE = [
|
|
303
|
+
"## Connect GitHub",
|
|
304
|
+
"",
|
|
305
|
+
"1. Create a **GitHub App** at https://github.com/settings/apps/new (or your org's",
|
|
306
|
+
" Developer settings). No webhook is needed — clear the **Webhook > Active** checkbox.",
|
|
307
|
+
"2. Repository permissions: **Contents** read, **Issues** read+write,",
|
|
308
|
+
" **Pull requests** read+write, **Metadata** read.",
|
|
309
|
+
"3. Under **Private keys**, generate a key and download the `.pem`.",
|
|
310
|
+
"4. **Install** the App on the account/org that owns the repos you want to watch.",
|
|
311
|
+
"5. On the next screens paste the App **ID**, the private key, the repos, and the triggers.",
|
|
312
|
+
].join("\n");
|
|
313
|
+
|
|
314
|
+
async function onboard(ctx: IntegrationOnboardContext): Promise<GithubAccount | undefined> {
|
|
315
|
+
ctx.ui.notify(ONBOARD_GUIDE, "info");
|
|
316
|
+
|
|
317
|
+
const appIdRaw = await ctx.ui.input("GitHub App ID (a number from the App's settings page)");
|
|
318
|
+
if (appIdRaw === undefined) return undefined;
|
|
319
|
+
const appId = appIdRaw.trim();
|
|
320
|
+
if (!appId) {
|
|
321
|
+
ctx.ui.notify("No App ID entered.", "error");
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const keyRaw = await ctx.ui.input("Private key: paste the PEM contents, or a $ENV / !cmd reference");
|
|
326
|
+
if (keyRaw === undefined) return undefined;
|
|
327
|
+
const privateKeyRef = keyRaw.trim();
|
|
328
|
+
if (!privateKeyRef) {
|
|
329
|
+
ctx.ui.notify("No private key entered.", "error");
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Verify the credentials with a live GET /app, and read the App slug for botLogin.
|
|
334
|
+
let botLogin: string;
|
|
335
|
+
try {
|
|
336
|
+
const privateKey = ctx.resolve(privateKeyRef) ?? privateKeyRef;
|
|
337
|
+
const jwt = createAppJwt(appId, privateKey);
|
|
338
|
+
const res = await githubRequest("GET", "/app", { token: jwt });
|
|
339
|
+
const app = parseJson<{ slug?: string; name?: string }>(res.text);
|
|
340
|
+
botLogin = typeof app?.slug === "string" ? app.slug : "";
|
|
341
|
+
if (!botLogin) throw new Error("App response had no slug");
|
|
342
|
+
ctx.ui.notify(`Verified GitHub App "${app?.name ?? botLogin}".`, "info");
|
|
343
|
+
} catch (err) {
|
|
344
|
+
ctx.ui.notify(`Could not verify the App: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const reposRaw = await ctx.ui.input("Repositories to watch, comma-separated (owner/repo, owner/repo)");
|
|
349
|
+
if (reposRaw === undefined) return undefined;
|
|
350
|
+
const candidates = reposRaw
|
|
351
|
+
.split(",")
|
|
352
|
+
.map((r) => r.trim())
|
|
353
|
+
.filter((r) => r.length > 0);
|
|
354
|
+
if (candidates.length === 0) {
|
|
355
|
+
ctx.ui.notify("No repositories entered.", "error");
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Validate each repo has the App installed; drop the ones that don't.
|
|
360
|
+
const privateKey = ctx.resolve(privateKeyRef) ?? privateKeyRef;
|
|
361
|
+
const repositories: string[] = [];
|
|
362
|
+
for (const full of candidates) {
|
|
363
|
+
try {
|
|
364
|
+
const { owner, repo } = parseRepo(full);
|
|
365
|
+
await resolveRepoInstallationId({ appId, privateKey }, owner, repo);
|
|
366
|
+
repositories.push(`${owner}/${repo}`);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
ctx.ui.notify(`Skipping ${full}: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (repositories.length === 0) {
|
|
372
|
+
ctx.ui.notify("None of the repositories have the App installed.", "error");
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const triggers: ("mention" | "auto")[] = [];
|
|
377
|
+
if (await ctx.ui.confirm("Triggers", `React when a comment @mentions @${botLogin}?`)) triggers.push("mention");
|
|
378
|
+
if (await ctx.ui.confirm("Triggers", "Auto-review pull requests when they are opened or updated?")) {
|
|
379
|
+
triggers.push("auto");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Store the private key as the entered reference ($ENV / !cmd resolve on read), not the resolved secret.
|
|
383
|
+
return { appId, privateKey: privateKeyRef, botLogin, repositories, triggers };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ============================================================================
|
|
387
|
+
// Shared typebox fragments
|
|
388
|
+
// ============================================================================
|
|
389
|
+
|
|
390
|
+
const AuthorFields = {
|
|
391
|
+
authorLogin: Type.String(),
|
|
392
|
+
authorType: Type.String(),
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
export default defineIntegration({
|
|
396
|
+
account: Type.Object({
|
|
397
|
+
/** GitHub App ID. */
|
|
398
|
+
appId: Type.String(),
|
|
399
|
+
/** App private key (PEM). Onboarding stores the entered reference; `$ENV`/`!cmd` resolve on read. */
|
|
400
|
+
privateKey: Type.String(),
|
|
401
|
+
/** App slug — used for @mention detection and self/loop filtering in the workflow. */
|
|
402
|
+
botLogin: Type.String(),
|
|
403
|
+
/** Repos to watch, as `owner/repo`. */
|
|
404
|
+
repositories: Type.Array(Type.String()),
|
|
405
|
+
/** Enabled trigger modes. */
|
|
406
|
+
triggers: Type.Array(Type.Union([Type.Literal("mention"), Type.Literal("auto")])),
|
|
407
|
+
/** Poll interval floor in ms (honoring GitHub's `X-Poll-Interval`); default 60s. */
|
|
408
|
+
pollIntervalMs: Type.Optional(Type.Number()),
|
|
409
|
+
}),
|
|
410
|
+
events: {
|
|
411
|
+
issue_comment: Type.Object({
|
|
412
|
+
repo: Type.String(),
|
|
413
|
+
isPullRequest: Type.Boolean(),
|
|
414
|
+
issueNumber: Type.Number(),
|
|
415
|
+
commentId: Type.Number(),
|
|
416
|
+
body: Type.String(),
|
|
417
|
+
...AuthorFields,
|
|
418
|
+
htmlUrl: Type.String(),
|
|
419
|
+
}),
|
|
420
|
+
pull_request_review_comment: Type.Object({
|
|
421
|
+
repo: Type.String(),
|
|
422
|
+
pullRequestNumber: Type.Number(),
|
|
423
|
+
commentId: Type.Number(),
|
|
424
|
+
inReplyToId: Type.Number(),
|
|
425
|
+
body: Type.String(),
|
|
426
|
+
...AuthorFields,
|
|
427
|
+
htmlUrl: Type.String(),
|
|
428
|
+
}),
|
|
429
|
+
issues: Type.Object({
|
|
430
|
+
repo: Type.String(),
|
|
431
|
+
action: Type.String(),
|
|
432
|
+
issueNumber: Type.Number(),
|
|
433
|
+
title: Type.String(),
|
|
434
|
+
...AuthorFields,
|
|
435
|
+
htmlUrl: Type.String(),
|
|
436
|
+
}),
|
|
437
|
+
pull_request: Type.Object({
|
|
438
|
+
repo: Type.String(),
|
|
439
|
+
action: Type.String(),
|
|
440
|
+
pullRequestNumber: Type.Number(),
|
|
441
|
+
headSha: Type.String(),
|
|
442
|
+
title: Type.String(),
|
|
443
|
+
draft: Type.Boolean(),
|
|
444
|
+
state: Type.String(),
|
|
445
|
+
authorLogin: Type.String(),
|
|
446
|
+
htmlUrl: Type.String(),
|
|
447
|
+
}),
|
|
448
|
+
},
|
|
449
|
+
onboard,
|
|
450
|
+
actions: {
|
|
451
|
+
// --- Internal: the workflow reads config through this (actions get ctx.account) ---
|
|
452
|
+
getSettings: {
|
|
453
|
+
description: "Read the bot login and enabled triggers for routing decisions.",
|
|
454
|
+
parameters: Type.Object({}),
|
|
455
|
+
execute: async (_params, ctx) => {
|
|
456
|
+
const account = ctx.account as GithubAccount;
|
|
457
|
+
return { botLogin: account.botLogin, triggers: account.triggers };
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
// --- Reads ---
|
|
462
|
+
getPullRequest: {
|
|
463
|
+
description: "Fetch pull-request metadata as JSON.",
|
|
464
|
+
parameters: Type.Object({ repo: Type.String(), pullRequestNumber: Type.Number() }),
|
|
465
|
+
execute: async (params, ctx) => {
|
|
466
|
+
const p = params as { repo: string; pullRequestNumber: number };
|
|
467
|
+
const account = ctx.account as GithubAccount;
|
|
468
|
+
const text = await repoRequest(account, ctx.store, p.repo, "GET", `/pulls/${p.pullRequestNumber}`);
|
|
469
|
+
return parseJson(text);
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
getPullRequestDiff: {
|
|
473
|
+
description: "Fetch the unified diff of a pull request.",
|
|
474
|
+
parameters: Type.Object({ repo: Type.String(), pullRequestNumber: Type.Number() }),
|
|
475
|
+
execute: async (params, ctx) => {
|
|
476
|
+
const p = params as { repo: string; pullRequestNumber: number };
|
|
477
|
+
const account = ctx.account as GithubAccount;
|
|
478
|
+
const diff = await repoRequest(account, ctx.store, p.repo, "GET", `/pulls/${p.pullRequestNumber}`, {
|
|
479
|
+
accept: "application/vnd.github.diff",
|
|
480
|
+
});
|
|
481
|
+
return { diff };
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
listPullRequestFiles: {
|
|
485
|
+
description: "List the files changed by a pull request.",
|
|
486
|
+
parameters: Type.Object({ repo: Type.String(), pullRequestNumber: Type.Number() }),
|
|
487
|
+
execute: async (params, ctx) => {
|
|
488
|
+
const p = params as { repo: string; pullRequestNumber: number };
|
|
489
|
+
const account = ctx.account as GithubAccount;
|
|
490
|
+
const text = await repoRequest(
|
|
491
|
+
account,
|
|
492
|
+
ctx.store,
|
|
493
|
+
p.repo,
|
|
494
|
+
"GET",
|
|
495
|
+
`/pulls/${p.pullRequestNumber}/files?per_page=100`,
|
|
496
|
+
);
|
|
497
|
+
return { files: parseJson(text) };
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
checkoutPullRequest: {
|
|
501
|
+
description:
|
|
502
|
+
"Materialize a read-only checkout of a pull request's head commit under the agent workspace, so the agent can review the real source tree with its file tools. Fetches head + base (depth 1); configures no remote and never persists the token.",
|
|
503
|
+
parameters: Type.Object({
|
|
504
|
+
repo: Type.String(),
|
|
505
|
+
pullRequestNumber: Type.Number(),
|
|
506
|
+
destDir: Type.String(),
|
|
507
|
+
headSha: Type.String(),
|
|
508
|
+
baseSha: Type.String(),
|
|
509
|
+
}),
|
|
510
|
+
execute: async (params, ctx) => {
|
|
511
|
+
const p = params as {
|
|
512
|
+
repo: string;
|
|
513
|
+
pullRequestNumber: number;
|
|
514
|
+
destDir: string;
|
|
515
|
+
headSha: string;
|
|
516
|
+
baseSha: string;
|
|
517
|
+
};
|
|
518
|
+
const account = ctx.account as GithubAccount;
|
|
519
|
+
const { owner, repo } = parseRepo(p.repo);
|
|
520
|
+
// Resolve (and cache) the installation token here so it stays inside the action — it is
|
|
521
|
+
// handed to git and never returned to the caller or written to disk.
|
|
522
|
+
const key = `installation:${owner}/${repo}`;
|
|
523
|
+
let installationId = ctx.store.get(key) as number | undefined;
|
|
524
|
+
if (installationId === undefined) {
|
|
525
|
+
installationId = await resolveRepoInstallationId(credentials(account), owner, repo);
|
|
526
|
+
ctx.store.set(key, installationId);
|
|
527
|
+
}
|
|
528
|
+
const token = await createInstallationToken(credentials(account), installationId);
|
|
529
|
+
await checkout({
|
|
530
|
+
destDir: p.destDir,
|
|
531
|
+
repo: `${owner}/${repo}`,
|
|
532
|
+
token,
|
|
533
|
+
headSha: p.headSha,
|
|
534
|
+
baseSha: p.baseSha,
|
|
535
|
+
});
|
|
536
|
+
return { path: p.destDir };
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
getIssue: {
|
|
540
|
+
description: "Fetch issue metadata as JSON.",
|
|
541
|
+
parameters: Type.Object({ repo: Type.String(), issueNumber: Type.Number() }),
|
|
542
|
+
execute: async (params, ctx) => {
|
|
543
|
+
const p = params as { repo: string; issueNumber: number };
|
|
544
|
+
const account = ctx.account as GithubAccount;
|
|
545
|
+
const text = await repoRequest(account, ctx.store, p.repo, "GET", `/issues/${p.issueNumber}`);
|
|
546
|
+
return parseJson(text);
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
getFileContent: {
|
|
550
|
+
description: "Fetch a file's raw contents at an optional ref.",
|
|
551
|
+
parameters: Type.Object({ repo: Type.String(), path: Type.String(), ref: Type.Optional(Type.String()) }),
|
|
552
|
+
execute: async (params, ctx) => {
|
|
553
|
+
const p = params as { repo: string; path: string; ref?: string };
|
|
554
|
+
const account = ctx.account as GithubAccount;
|
|
555
|
+
const query = p.ref ? `?ref=${encodeURIComponent(p.ref)}` : "";
|
|
556
|
+
const content = await repoRequest(
|
|
557
|
+
account,
|
|
558
|
+
ctx.store,
|
|
559
|
+
p.repo,
|
|
560
|
+
"GET",
|
|
561
|
+
`/contents/${p.path.split("/").map(encodeURIComponent).join("/")}${query}`,
|
|
562
|
+
{ accept: "application/vnd.github.raw" },
|
|
563
|
+
);
|
|
564
|
+
return { content };
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
listComments: {
|
|
568
|
+
description: "List the timeline comments on an issue or pull request.",
|
|
569
|
+
parameters: Type.Object({ repo: Type.String(), issueNumber: Type.Number() }),
|
|
570
|
+
execute: async (params, ctx) => {
|
|
571
|
+
const p = params as { repo: string; issueNumber: number };
|
|
572
|
+
const account = ctx.account as GithubAccount;
|
|
573
|
+
const text = await repoRequest(
|
|
574
|
+
account,
|
|
575
|
+
ctx.store,
|
|
576
|
+
p.repo,
|
|
577
|
+
"GET",
|
|
578
|
+
`/issues/${p.issueNumber}/comments?per_page=100`,
|
|
579
|
+
);
|
|
580
|
+
return { comments: parseJson(text) };
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
// --- Writes ---
|
|
585
|
+
postComment: {
|
|
586
|
+
description: "Post a timeline comment on an issue or pull request.",
|
|
587
|
+
parameters: Type.Object({ repo: Type.String(), issueNumber: Type.Number(), body: Type.String() }),
|
|
588
|
+
execute: async (params, ctx) => {
|
|
589
|
+
const p = params as { repo: string; issueNumber: number; body: string };
|
|
590
|
+
const account = ctx.account as GithubAccount;
|
|
591
|
+
const text = await repoRequest(account, ctx.store, p.repo, "POST", `/issues/${p.issueNumber}/comments`, {
|
|
592
|
+
body: { body: p.body },
|
|
593
|
+
});
|
|
594
|
+
return parseJson(text);
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
createReviewComment: {
|
|
598
|
+
description: "Create an inline review comment on a pull request diff line.",
|
|
599
|
+
parameters: Type.Object({
|
|
600
|
+
repo: Type.String(),
|
|
601
|
+
pullRequestNumber: Type.Number(),
|
|
602
|
+
body: Type.String(),
|
|
603
|
+
commitId: Type.String(),
|
|
604
|
+
path: Type.String(),
|
|
605
|
+
line: Type.Number(),
|
|
606
|
+
side: Type.Optional(Type.String()),
|
|
607
|
+
}),
|
|
608
|
+
execute: async (params, ctx) => {
|
|
609
|
+
const p = params as {
|
|
610
|
+
repo: string;
|
|
611
|
+
pullRequestNumber: number;
|
|
612
|
+
body: string;
|
|
613
|
+
commitId: string;
|
|
614
|
+
path: string;
|
|
615
|
+
line: number;
|
|
616
|
+
side?: string;
|
|
617
|
+
};
|
|
618
|
+
const account = ctx.account as GithubAccount;
|
|
619
|
+
const text = await repoRequest(account, ctx.store, p.repo, "POST", `/pulls/${p.pullRequestNumber}/comments`, {
|
|
620
|
+
body: { body: p.body, commit_id: p.commitId, path: p.path, line: p.line, side: p.side ?? "RIGHT" },
|
|
621
|
+
});
|
|
622
|
+
return parseJson(text);
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
submitReview: {
|
|
626
|
+
description: "Submit a pull-request review (COMMENT / APPROVE / REQUEST_CHANGES).",
|
|
627
|
+
parameters: Type.Object({
|
|
628
|
+
repo: Type.String(),
|
|
629
|
+
pullRequestNumber: Type.Number(),
|
|
630
|
+
event: Type.Union([Type.Literal("COMMENT"), Type.Literal("APPROVE"), Type.Literal("REQUEST_CHANGES")]),
|
|
631
|
+
body: Type.Optional(Type.String()),
|
|
632
|
+
}),
|
|
633
|
+
execute: async (params, ctx) => {
|
|
634
|
+
const p = params as {
|
|
635
|
+
repo: string;
|
|
636
|
+
pullRequestNumber: number;
|
|
637
|
+
event: string;
|
|
638
|
+
body?: string;
|
|
639
|
+
};
|
|
640
|
+
const account = ctx.account as GithubAccount;
|
|
641
|
+
const text = await repoRequest(account, ctx.store, p.repo, "POST", `/pulls/${p.pullRequestNumber}/reviews`, {
|
|
642
|
+
body: { event: p.event, body: p.body },
|
|
643
|
+
});
|
|
644
|
+
return parseJson(text);
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
replyToReviewComment: {
|
|
648
|
+
description: "Reply to an inline pull-request review comment thread.",
|
|
649
|
+
parameters: Type.Object({
|
|
650
|
+
repo: Type.String(),
|
|
651
|
+
pullRequestNumber: Type.Number(),
|
|
652
|
+
commentId: Type.Number(),
|
|
653
|
+
body: Type.String(),
|
|
654
|
+
}),
|
|
655
|
+
execute: async (params, ctx) => {
|
|
656
|
+
const p = params as { repo: string; pullRequestNumber: number; commentId: number; body: string };
|
|
657
|
+
const account = ctx.account as GithubAccount;
|
|
658
|
+
const text = await repoRequest(
|
|
659
|
+
account,
|
|
660
|
+
ctx.store,
|
|
661
|
+
p.repo,
|
|
662
|
+
"POST",
|
|
663
|
+
`/pulls/${p.pullRequestNumber}/comments/${p.commentId}/replies`,
|
|
664
|
+
{ body: { body: p.body } },
|
|
665
|
+
);
|
|
666
|
+
return parseJson(text);
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
addReaction: {
|
|
670
|
+
description: "Add a reaction emoji to an issue comment or review comment.",
|
|
671
|
+
parameters: Type.Object({
|
|
672
|
+
repo: Type.String(),
|
|
673
|
+
subject: Type.Union([Type.Literal("issue_comment"), Type.Literal("pull_request_review_comment")]),
|
|
674
|
+
commentId: Type.Number(),
|
|
675
|
+
content: Type.String(),
|
|
676
|
+
}),
|
|
677
|
+
execute: async (params, ctx) => {
|
|
678
|
+
const p = params as {
|
|
679
|
+
repo: string;
|
|
680
|
+
subject: "issue_comment" | "pull_request_review_comment";
|
|
681
|
+
commentId: number;
|
|
682
|
+
content: string;
|
|
683
|
+
};
|
|
684
|
+
const account = ctx.account as GithubAccount;
|
|
685
|
+
const segment = p.subject === "issue_comment" ? "issues/comments" : "pulls/comments";
|
|
686
|
+
const text = await repoRequest(
|
|
687
|
+
account,
|
|
688
|
+
ctx.store,
|
|
689
|
+
p.repo,
|
|
690
|
+
"POST",
|
|
691
|
+
`/${segment}/${p.commentId}/reactions`,
|
|
692
|
+
{ body: { content: p.content } },
|
|
693
|
+
);
|
|
694
|
+
return parseJson(text);
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
addLabels: {
|
|
698
|
+
description: "Add labels to an issue or pull request.",
|
|
699
|
+
parameters: Type.Object({ repo: Type.String(), issueNumber: Type.Number(), labels: Type.Array(Type.String()) }),
|
|
700
|
+
execute: async (params, ctx) => {
|
|
701
|
+
const p = params as { repo: string; issueNumber: number; labels: string[] };
|
|
702
|
+
const account = ctx.account as GithubAccount;
|
|
703
|
+
const text = await repoRequest(account, ctx.store, p.repo, "POST", `/issues/${p.issueNumber}/labels`, {
|
|
704
|
+
body: { labels: p.labels },
|
|
705
|
+
});
|
|
706
|
+
return parseJson(text);
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
requestReviewers: {
|
|
710
|
+
description: "Request reviewers on a pull request.",
|
|
711
|
+
parameters: Type.Object({
|
|
712
|
+
repo: Type.String(),
|
|
713
|
+
pullRequestNumber: Type.Number(),
|
|
714
|
+
reviewers: Type.Array(Type.String()),
|
|
715
|
+
}),
|
|
716
|
+
execute: async (params, ctx) => {
|
|
717
|
+
const p = params as { repo: string; pullRequestNumber: number; reviewers: string[] };
|
|
718
|
+
const account = ctx.account as GithubAccount;
|
|
719
|
+
const text = await repoRequest(
|
|
720
|
+
account,
|
|
721
|
+
ctx.store,
|
|
722
|
+
p.repo,
|
|
723
|
+
"POST",
|
|
724
|
+
`/pulls/${p.pullRequestNumber}/requested_reviewers`,
|
|
725
|
+
{ body: { reviewers: p.reviewers } },
|
|
726
|
+
);
|
|
727
|
+
return parseJson(text);
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
setCommitStatus: {
|
|
731
|
+
description: "Set a commit status on a SHA.",
|
|
732
|
+
parameters: Type.Object({
|
|
733
|
+
repo: Type.String(),
|
|
734
|
+
sha: Type.String(),
|
|
735
|
+
state: Type.Union([
|
|
736
|
+
Type.Literal("error"),
|
|
737
|
+
Type.Literal("failure"),
|
|
738
|
+
Type.Literal("pending"),
|
|
739
|
+
Type.Literal("success"),
|
|
740
|
+
]),
|
|
741
|
+
context: Type.Optional(Type.String()),
|
|
742
|
+
description: Type.Optional(Type.String()),
|
|
743
|
+
targetUrl: Type.Optional(Type.String()),
|
|
744
|
+
}),
|
|
745
|
+
execute: async (params, ctx) => {
|
|
746
|
+
const p = params as {
|
|
747
|
+
repo: string;
|
|
748
|
+
sha: string;
|
|
749
|
+
state: string;
|
|
750
|
+
context?: string;
|
|
751
|
+
description?: string;
|
|
752
|
+
targetUrl?: string;
|
|
753
|
+
};
|
|
754
|
+
const account = ctx.account as GithubAccount;
|
|
755
|
+
const text = await repoRequest(account, ctx.store, p.repo, "POST", `/statuses/${p.sha}`, {
|
|
756
|
+
body: { state: p.state, context: p.context, description: p.description, target_url: p.targetUrl },
|
|
757
|
+
});
|
|
758
|
+
return parseJson(text);
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
run(ctx) {
|
|
763
|
+
const account = ctx.account as GithubAccount;
|
|
764
|
+
if (account.repositories.length === 0) {
|
|
765
|
+
console.warn("[github] no repositories configured — the poller has nothing to watch.");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
let stopped = false;
|
|
769
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
770
|
+
let pollFloorMs = 0;
|
|
771
|
+
|
|
772
|
+
const loop = async (): Promise<void> => {
|
|
773
|
+
if (stopped || ctx.signal.aborted) return;
|
|
774
|
+
try {
|
|
775
|
+
// Swallow per-tick errors so one failure never stops the loop (telegram's posture).
|
|
776
|
+
pollFloorMs = await tick(account, ctx.store, (event, data) => ctx.emit(event as Stream, data as never));
|
|
777
|
+
} catch (err) {
|
|
778
|
+
console.error("[github] poll tick failed:", err instanceof Error ? err.message : err);
|
|
779
|
+
}
|
|
780
|
+
if (stopped || ctx.signal.aborted) return;
|
|
781
|
+
const delay = Math.max(account.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, pollFloorMs);
|
|
782
|
+
timer = setTimeout(() => void loop(), delay);
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
// Fire-and-forget; the first pass is the catch-up tick (resumes from the stored cursor).
|
|
786
|
+
void loop();
|
|
787
|
+
|
|
788
|
+
const dispose = () => {
|
|
789
|
+
stopped = true;
|
|
790
|
+
if (timer) clearTimeout(timer);
|
|
791
|
+
};
|
|
792
|
+
ctx.signal.addEventListener("abort", dispose);
|
|
793
|
+
return dispose;
|
|
794
|
+
},
|
|
795
|
+
});
|