wolli 0.0.4 → 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/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/dist/cli.js +5439 -5441
- package/package.json +1 -1
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App auth + REST + normalization helpers for the polling integration.
|
|
3
|
+
*
|
|
4
|
+
* This module holds every piece of the transport that does NOT depend on the wolli host:
|
|
5
|
+
* the App JWT (RS256 via `node:crypto`), the installation-token exchange + cache, the thin
|
|
6
|
+
* `fetch`-based REST wrapper, the raw-JSON normalizers, the `@mention` / loop-prevention
|
|
7
|
+
* gating, and the pure cursor-selection logic the poll loop drives. Keeping it host-free
|
|
8
|
+
* (no `import ... from "wolli"`) is what makes it unit-testable on its own — `index.ts`
|
|
9
|
+
* imports these into the `defineIntegration(...)` transport and `github-chat.ts` imports
|
|
10
|
+
* the gating helpers into the routing workflows.
|
|
11
|
+
*
|
|
12
|
+
* GitHub App auth is standard and implemented in-repo (no third-party SDK): a short-lived
|
|
13
|
+
* RS256 JWT signed with the App private key, exchanged per installation for a ~1h token.
|
|
14
|
+
* The eve `channels/github/{auth,api,inbound}.ts` sources are the working reference these
|
|
15
|
+
* calls were ported from.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createSign } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
/** GitHub REST base. */
|
|
21
|
+
export const GITHUB_API_BASE = "https://api.github.com";
|
|
22
|
+
|
|
23
|
+
/** X-GitHub-Api-Version the calls pin to. */
|
|
24
|
+
const GITHUB_API_VERSION = "2022-11-28";
|
|
25
|
+
|
|
26
|
+
/** GitHub caps a single issue/PR comment body at 65536 characters. */
|
|
27
|
+
export const GITHUB_COMMENT_MAX_LENGTH = 65_536;
|
|
28
|
+
|
|
29
|
+
/** Default poll floor when the account sets none and GitHub returns no X-Poll-Interval. */
|
|
30
|
+
export const DEFAULT_POLL_INTERVAL_MS = 60_000;
|
|
31
|
+
|
|
32
|
+
/** Refresh an installation token this far before its reported expiry. */
|
|
33
|
+
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/** Loop-prevention marker appended to every comment the agent posts. */
|
|
36
|
+
export const GITHUB_COMMENT_MARKER = "<!-- wolli:github -->";
|
|
37
|
+
|
|
38
|
+
/** Error thrown for a non-2xx (and non-304) GitHub REST response. */
|
|
39
|
+
export class GithubApiError extends Error {
|
|
40
|
+
readonly status: number;
|
|
41
|
+
readonly body: string;
|
|
42
|
+
constructor(method: string, path: string, status: number, body: string) {
|
|
43
|
+
super(`GitHub ${method} ${path} failed with HTTP ${status}`);
|
|
44
|
+
this.name = "GithubApiError";
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.body = body;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for the low-level {@link githubRequest} wrapper. */
|
|
51
|
+
export interface GithubRequestOptions {
|
|
52
|
+
/** Bearer token: an installation token or an App JWT. */
|
|
53
|
+
token?: string;
|
|
54
|
+
/** Override the default `application/vnd.github+json` Accept (e.g. the diff or raw media type). */
|
|
55
|
+
accept?: string;
|
|
56
|
+
/** JSON request body; serialized and sent with a JSON content-type. */
|
|
57
|
+
body?: unknown;
|
|
58
|
+
/** ETag for a conditional request; a matching resource returns 304. */
|
|
59
|
+
etag?: string;
|
|
60
|
+
/** Override the API base (tests point this at a mock server). */
|
|
61
|
+
baseUrl?: string;
|
|
62
|
+
/** Override the fetch implementation (tests inject a mock). */
|
|
63
|
+
fetchImpl?: typeof fetch;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A GitHub REST response, body left as text so diff/raw media types pass through unparsed. */
|
|
67
|
+
export interface GithubResponse {
|
|
68
|
+
status: number;
|
|
69
|
+
etag: string | null;
|
|
70
|
+
/** `X-Poll-Interval` in ms when GitHub returned one, else null. */
|
|
71
|
+
pollIntervalMs: number | null;
|
|
72
|
+
text: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Thin REST wrapper. Throws {@link GithubApiError} on non-2xx except 304 (returned to the caller). */
|
|
76
|
+
export async function githubRequest(
|
|
77
|
+
method: string,
|
|
78
|
+
path: string,
|
|
79
|
+
options: GithubRequestOptions = {},
|
|
80
|
+
): Promise<GithubResponse> {
|
|
81
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
82
|
+
const headers: Record<string, string> = {
|
|
83
|
+
accept: options.accept ?? "application/vnd.github+json",
|
|
84
|
+
"x-github-api-version": GITHUB_API_VERSION,
|
|
85
|
+
"user-agent": "wolli-github-integration",
|
|
86
|
+
};
|
|
87
|
+
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
88
|
+
if (options.etag) headers["if-none-match"] = options.etag;
|
|
89
|
+
if (options.body !== undefined) headers["content-type"] = "application/json; charset=utf-8";
|
|
90
|
+
|
|
91
|
+
const response = await doFetch(`${options.baseUrl ?? GITHUB_API_BASE}${path}`, {
|
|
92
|
+
method,
|
|
93
|
+
headers,
|
|
94
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
95
|
+
});
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
if (!response.ok && response.status !== 304) {
|
|
98
|
+
throw new GithubApiError(method, path, response.status, text);
|
|
99
|
+
}
|
|
100
|
+
const poll = response.headers.get("x-poll-interval");
|
|
101
|
+
return {
|
|
102
|
+
status: response.status,
|
|
103
|
+
etag: response.headers.get("etag"),
|
|
104
|
+
pollIntervalMs: poll ? Number(poll) * 1000 : null,
|
|
105
|
+
text,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse a JSON response body, throwing a contextual error if it is not valid JSON. */
|
|
110
|
+
export function parseJson<T = unknown>(text: string): T {
|
|
111
|
+
if (!text) return null as T;
|
|
112
|
+
return JSON.parse(text) as T;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// App auth
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
/** Convert hosted-platform escaped newlines back into PEM newlines. */
|
|
120
|
+
export function normalizePrivateKey(privateKey: string): string {
|
|
121
|
+
return privateKey.replace(/\\n/g, "\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function base64UrlJson(value: unknown): string {
|
|
125
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mint a short-lived RS256 GitHub App JWT (`iss` = app id, ~10-min expiry, `iat` backdated
|
|
130
|
+
* 60s for clock skew). `nowMs` is injectable so tests assert a deterministic `iat`/`exp`.
|
|
131
|
+
*/
|
|
132
|
+
export function createAppJwt(appId: string, privateKey: string, nowMs: number = Date.now()): string {
|
|
133
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
134
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
135
|
+
const payload = { iat: nowSeconds - 60, exp: nowSeconds + 10 * 60, iss: appId };
|
|
136
|
+
const signingInput = `${base64UrlJson(header)}.${base64UrlJson(payload)}`;
|
|
137
|
+
const signature = createSign("RSA-SHA256").update(signingInput).sign(normalizePrivateKey(privateKey), "base64url");
|
|
138
|
+
return `${signingInput}.${signature}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface CachedToken {
|
|
142
|
+
token: string;
|
|
143
|
+
expiresAtMs: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Process-memory installation-token cache, keyed by `${appId}:${installationId}`. */
|
|
147
|
+
const installationTokenCache = new Map<string, CachedToken>();
|
|
148
|
+
|
|
149
|
+
/** Clear the installation-token cache. Intended for tests. */
|
|
150
|
+
export function clearInstallationTokenCache(): void {
|
|
151
|
+
installationTokenCache.clear();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Credentials the App-auth helpers need. */
|
|
155
|
+
export interface AppCredentials {
|
|
156
|
+
appId: string;
|
|
157
|
+
privateKey: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Exchange the App JWT for a per-installation access token, cached in process memory until
|
|
162
|
+
* ~1 min before GitHub's reported expiry. `POST /app/installations/{id}/access_tokens`.
|
|
163
|
+
*/
|
|
164
|
+
export async function createInstallationToken(
|
|
165
|
+
credentials: AppCredentials,
|
|
166
|
+
installationId: number,
|
|
167
|
+
options: { baseUrl?: string; fetchImpl?: typeof fetch; nowMs?: number } = {},
|
|
168
|
+
): Promise<string> {
|
|
169
|
+
const now = options.nowMs ?? Date.now();
|
|
170
|
+
const cacheKey = `${credentials.appId}:${installationId}`;
|
|
171
|
+
const cached = installationTokenCache.get(cacheKey);
|
|
172
|
+
if (cached && now < cached.expiresAtMs - TOKEN_REFRESH_SKEW_MS) {
|
|
173
|
+
return cached.token;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const jwt = createAppJwt(credentials.appId, credentials.privateKey, now);
|
|
177
|
+
const result = await githubRequest("POST", `/app/installations/${installationId}/access_tokens`, {
|
|
178
|
+
token: jwt,
|
|
179
|
+
baseUrl: options.baseUrl,
|
|
180
|
+
fetchImpl: options.fetchImpl,
|
|
181
|
+
});
|
|
182
|
+
const body = parseJson<{ token?: string; expires_at?: string }>(result.text);
|
|
183
|
+
if (!body || typeof body.token !== "string") {
|
|
184
|
+
throw new Error("github: installation token response did not include a token");
|
|
185
|
+
}
|
|
186
|
+
const expiresAtMs = body.expires_at ? Date.parse(body.expires_at) : now + 60 * 60 * 1000;
|
|
187
|
+
installationTokenCache.set(cacheKey, {
|
|
188
|
+
token: body.token,
|
|
189
|
+
expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : now + 60 * 60 * 1000,
|
|
190
|
+
});
|
|
191
|
+
return body.token;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve the installation id an App holds over a repo: `GET /repos/{o}/{r}/installation`
|
|
196
|
+
* authenticated with the App JWT. An App is installed per org/user; tokens are per-installation.
|
|
197
|
+
*/
|
|
198
|
+
export async function resolveRepoInstallationId(
|
|
199
|
+
credentials: AppCredentials,
|
|
200
|
+
owner: string,
|
|
201
|
+
repo: string,
|
|
202
|
+
options: { baseUrl?: string; fetchImpl?: typeof fetch; nowMs?: number } = {},
|
|
203
|
+
): Promise<number> {
|
|
204
|
+
const jwt = createAppJwt(credentials.appId, credentials.privateKey, options.nowMs ?? Date.now());
|
|
205
|
+
const result = await githubRequest("GET", `/repos/${owner}/${repo}/installation`, {
|
|
206
|
+
token: jwt,
|
|
207
|
+
baseUrl: options.baseUrl,
|
|
208
|
+
fetchImpl: options.fetchImpl,
|
|
209
|
+
});
|
|
210
|
+
const body = parseJson<{ id?: number }>(result.text);
|
|
211
|
+
if (!body || typeof body.id !== "number") {
|
|
212
|
+
throw new Error(`github: no installation id for ${owner}/${repo} (is the App installed there?)`);
|
|
213
|
+
}
|
|
214
|
+
return body.id;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// Repo / thread parsing
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
/** Split `"owner/repo"`; throws on a malformed value. */
|
|
222
|
+
export function parseRepo(full: string): { owner: string; repo: string } {
|
|
223
|
+
const [owner, repo] = full.split("/");
|
|
224
|
+
if (!owner || !repo) throw new Error(`github: invalid repository "${full}" (expected "owner/repo")`);
|
|
225
|
+
return { owner, repo };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Parse a `github:thread` tag (`"owner/repo#123"`) back into its parts. */
|
|
229
|
+
export function parseThreadTag(tag: string): { repo: string; number: number } {
|
|
230
|
+
const hash = tag.lastIndexOf("#");
|
|
231
|
+
if (hash === -1) throw new Error(`github: invalid thread tag "${tag}"`);
|
|
232
|
+
const repo = tag.slice(0, hash);
|
|
233
|
+
const number = Number(tag.slice(hash + 1));
|
|
234
|
+
if (!repo || !Number.isFinite(number)) throw new Error(`github: invalid thread tag "${tag}"`);
|
|
235
|
+
return { repo, number };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Extract the issue/PR number from a GitHub API resource URL (`.../issues/123`, `.../pulls/45`). */
|
|
239
|
+
function numberFromUrl(url: string | undefined, segment: string): number {
|
|
240
|
+
if (!url) return 0;
|
|
241
|
+
const match = new RegExp(`/${segment}/(\\d+)`).exec(url);
|
|
242
|
+
return match ? Number(match[1]) : 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Normalizers (raw GitHub JSON -> lean emitted payloads)
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
/** A decoded, still-untyped GitHub JSON object. */
|
|
250
|
+
export type RawJson = Record<string, unknown>;
|
|
251
|
+
|
|
252
|
+
function isRecord(value: unknown): value is RawJson {
|
|
253
|
+
return typeof value === "object" && value !== null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Read the `.user` actor login/type off any GitHub resource object. */
|
|
257
|
+
function readAuthor(raw: RawJson): { authorLogin: string; authorType: string } {
|
|
258
|
+
const user = isRecord(raw.user) ? raw.user : {};
|
|
259
|
+
return {
|
|
260
|
+
authorLogin: typeof user.login === "string" ? user.login : "",
|
|
261
|
+
authorType: typeof user.type === "string" ? user.type : "User",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Read `.head.sha` off a raw pull-request object (`""` when absent). */
|
|
266
|
+
export function readHeadSha(raw: RawJson): string {
|
|
267
|
+
const head = isRecord(raw.head) ? raw.head : {};
|
|
268
|
+
return typeof head.sha === "string" ? head.sha : "";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Normalized issue/PR timeline comment (`issue_comment` stream). */
|
|
272
|
+
export interface IssueCommentPayload {
|
|
273
|
+
repo: string;
|
|
274
|
+
isPullRequest: boolean;
|
|
275
|
+
issueNumber: number;
|
|
276
|
+
commentId: number;
|
|
277
|
+
body: string;
|
|
278
|
+
authorLogin: string;
|
|
279
|
+
authorType: string;
|
|
280
|
+
htmlUrl: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function normalizeIssueComment(repo: string, raw: RawJson): IssueCommentPayload {
|
|
284
|
+
const htmlUrl = typeof raw.html_url === "string" ? raw.html_url : "";
|
|
285
|
+
return {
|
|
286
|
+
repo,
|
|
287
|
+
isPullRequest: htmlUrl.includes("/pull/"),
|
|
288
|
+
issueNumber: numberFromUrl(typeof raw.issue_url === "string" ? raw.issue_url : htmlUrl, "issues"),
|
|
289
|
+
commentId: typeof raw.id === "number" ? raw.id : 0,
|
|
290
|
+
body: typeof raw.body === "string" ? raw.body : "",
|
|
291
|
+
...readAuthor(raw),
|
|
292
|
+
htmlUrl,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Normalized inline PR review comment (`pull_request_review_comment` stream). */
|
|
297
|
+
export interface ReviewCommentPayload {
|
|
298
|
+
repo: string;
|
|
299
|
+
pullRequestNumber: number;
|
|
300
|
+
commentId: number;
|
|
301
|
+
inReplyToId: number;
|
|
302
|
+
body: string;
|
|
303
|
+
authorLogin: string;
|
|
304
|
+
authorType: string;
|
|
305
|
+
htmlUrl: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function normalizeReviewComment(repo: string, raw: RawJson): ReviewCommentPayload {
|
|
309
|
+
return {
|
|
310
|
+
repo,
|
|
311
|
+
pullRequestNumber: numberFromUrl(typeof raw.pull_request_url === "string" ? raw.pull_request_url : "", "pulls"),
|
|
312
|
+
commentId: typeof raw.id === "number" ? raw.id : 0,
|
|
313
|
+
inReplyToId: typeof raw.in_reply_to_id === "number" ? raw.in_reply_to_id : 0,
|
|
314
|
+
body: typeof raw.body === "string" ? raw.body : "",
|
|
315
|
+
...readAuthor(raw),
|
|
316
|
+
htmlUrl: typeof raw.html_url === "string" ? raw.html_url : "",
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Best-effort webhook-style action for a polled issue (no webhook `action` exists). */
|
|
321
|
+
function issueAction(raw: RawJson): string {
|
|
322
|
+
if (raw.state === "closed") return "closed";
|
|
323
|
+
if (raw.created_at && raw.created_at === raw.updated_at) return "opened";
|
|
324
|
+
return "edited";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Normalized issue (`issues` stream). PRs surfacing in the issues list are filtered out upstream. */
|
|
328
|
+
export interface IssuePayload {
|
|
329
|
+
repo: string;
|
|
330
|
+
action: string;
|
|
331
|
+
issueNumber: number;
|
|
332
|
+
title: string;
|
|
333
|
+
authorLogin: string;
|
|
334
|
+
authorType: string;
|
|
335
|
+
htmlUrl: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function normalizeIssue(repo: string, raw: RawJson): IssuePayload {
|
|
339
|
+
return {
|
|
340
|
+
repo,
|
|
341
|
+
action: issueAction(raw),
|
|
342
|
+
issueNumber: typeof raw.number === "number" ? raw.number : 0,
|
|
343
|
+
title: typeof raw.title === "string" ? raw.title : "",
|
|
344
|
+
...readAuthor(raw),
|
|
345
|
+
htmlUrl: typeof raw.html_url === "string" ? raw.html_url : "",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Best-effort webhook-style action for a polled PR (no webhook `action` exists). */
|
|
350
|
+
function pullRequestAction(raw: RawJson): string {
|
|
351
|
+
if (raw.state === "closed") return "closed";
|
|
352
|
+
if (raw.created_at && raw.created_at === raw.updated_at) return "opened";
|
|
353
|
+
return "synchronize";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Normalized pull request (`pull_request` stream). */
|
|
357
|
+
export interface PullRequestPayload {
|
|
358
|
+
repo: string;
|
|
359
|
+
action: string;
|
|
360
|
+
pullRequestNumber: number;
|
|
361
|
+
headSha: string;
|
|
362
|
+
title: string;
|
|
363
|
+
draft: boolean;
|
|
364
|
+
state: string;
|
|
365
|
+
authorLogin: string;
|
|
366
|
+
htmlUrl: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function normalizePullRequest(repo: string, raw: RawJson): PullRequestPayload {
|
|
370
|
+
return {
|
|
371
|
+
repo,
|
|
372
|
+
action: pullRequestAction(raw),
|
|
373
|
+
pullRequestNumber: typeof raw.number === "number" ? raw.number : 0,
|
|
374
|
+
headSha: readHeadSha(raw),
|
|
375
|
+
title: typeof raw.title === "string" ? raw.title : "",
|
|
376
|
+
draft: raw.draft === true,
|
|
377
|
+
state: typeof raw.state === "string" ? raw.state : "",
|
|
378
|
+
authorLogin: readAuthor(raw).authorLogin,
|
|
379
|
+
htmlUrl: typeof raw.html_url === "string" ? raw.html_url : "",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ============================================================================
|
|
384
|
+
// Mention + loop-prevention gating
|
|
385
|
+
// ============================================================================
|
|
386
|
+
|
|
387
|
+
function escapeRegExp(value: string): string {
|
|
388
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* True when an inbound comment must NOT wake the agent: it carries our own marker, is
|
|
393
|
+
* authored by a Bot, or is authored by this App's own bot login (`<slug>[bot]`). This is
|
|
394
|
+
* the loop guard — the App's own replies would otherwise re-trigger the mention path.
|
|
395
|
+
*/
|
|
396
|
+
export function isIgnoredInboundComment(
|
|
397
|
+
body: string,
|
|
398
|
+
authorLogin: string,
|
|
399
|
+
authorType: string,
|
|
400
|
+
botLogin: string,
|
|
401
|
+
): boolean {
|
|
402
|
+
if (body.includes(GITHUB_COMMENT_MARKER)) return true;
|
|
403
|
+
if (authorType === "Bot") return true;
|
|
404
|
+
const self = botLogin ? `${botLogin}[bot]`.toLowerCase() : "";
|
|
405
|
+
return self.length > 0 && authorLogin.toLowerCase() === self;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* If `body` @mentions `botLogin`, return the body with that mention stripped (the message
|
|
410
|
+
* to route to the agent); else null. Matches `@slug` on a word boundary, case-insensitively.
|
|
411
|
+
*/
|
|
412
|
+
export function extractMention(body: string, botLogin: string): { message: string } | null {
|
|
413
|
+
const login = botLogin.trim();
|
|
414
|
+
if (!login) return null;
|
|
415
|
+
const match = new RegExp(`@${escapeRegExp(login)}(?=$|[^A-Za-z0-9_-])`, "i").exec(body);
|
|
416
|
+
if (!match) return null;
|
|
417
|
+
const message = `${body.slice(0, match.index)}${body.slice(match.index + match[0].length)}`.trim();
|
|
418
|
+
return { message };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Split a comment body into GitHub-sized chunks, preferring newline/space boundaries. */
|
|
422
|
+
export function chunkComment(body: string, max = GITHUB_COMMENT_MAX_LENGTH): string[] {
|
|
423
|
+
if (body.length <= max) return [body];
|
|
424
|
+
const chunks: string[] = [];
|
|
425
|
+
let remaining = body;
|
|
426
|
+
while (remaining.length > max) {
|
|
427
|
+
let splitAt = remaining.lastIndexOf("\n", max);
|
|
428
|
+
if (splitAt <= max * 0.5) splitAt = remaining.lastIndexOf(" ", max);
|
|
429
|
+
if (splitAt <= max * 0.5) splitAt = max;
|
|
430
|
+
chunks.push(remaining.slice(0, splitAt).trimEnd());
|
|
431
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
432
|
+
}
|
|
433
|
+
if (remaining.length > 0) chunks.push(remaining);
|
|
434
|
+
return chunks;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ============================================================================
|
|
438
|
+
// Pure cursor selection (drives the poll loop; unit-tested in isolation)
|
|
439
|
+
// ============================================================================
|
|
440
|
+
|
|
441
|
+
/** One polled item reduced to the two fields the cursor logic needs. */
|
|
442
|
+
export interface CursorItem {
|
|
443
|
+
id: number;
|
|
444
|
+
/** ISO-8601 `updated_at`. */
|
|
445
|
+
ts: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Given items from a `?since=<cursor>` fetch, the stored `cursor` (max `updated_at` emitted
|
|
450
|
+
* so far), and `seenIds` (ids observed exactly at `cursor`), return the items to emit plus
|
|
451
|
+
* the advanced cursor/seen state. Emits anything strictly newer than the cursor, plus any
|
|
452
|
+
* same-second item whose id was not already seen — so a boundary re-fetch never re-emits,
|
|
453
|
+
* and a genuinely new same-second item is not dropped.
|
|
454
|
+
*/
|
|
455
|
+
export function selectSinceItems<T extends CursorItem>(
|
|
456
|
+
items: T[],
|
|
457
|
+
cursor: string,
|
|
458
|
+
seenIds: number[],
|
|
459
|
+
): { toEmit: T[]; nextCursor: string; nextSeenIds: number[] } {
|
|
460
|
+
const seen = new Set(seenIds);
|
|
461
|
+
const sorted = [...items].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
462
|
+
const toEmit = sorted.filter((it) => it.ts > cursor || (it.ts === cursor && !seen.has(it.id)));
|
|
463
|
+
|
|
464
|
+
let nextCursor = cursor;
|
|
465
|
+
for (const it of sorted) if (it.ts > nextCursor) nextCursor = it.ts;
|
|
466
|
+
|
|
467
|
+
const atBoundary = sorted.filter((it) => it.ts === nextCursor).map((it) => it.id);
|
|
468
|
+
const nextSeenIds = nextCursor === cursor ? [...new Set([...seenIds, ...atBoundary])] : atBoundary;
|
|
469
|
+
return { toEmit, nextCursor, nextSeenIds };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** One polled PR reduced to the fields the head-SHA dedupe needs. */
|
|
473
|
+
export interface PullRequestCursorItem {
|
|
474
|
+
number: number;
|
|
475
|
+
headSha: string;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Given the current PR list and the last-emitted head SHA per PR number, return the PRs whose
|
|
480
|
+
* head advanced (or are new) plus the refreshed head map. Rebuilding the map from the fetch
|
|
481
|
+
* bounds it to the recent-updates window rather than growing without limit.
|
|
482
|
+
*/
|
|
483
|
+
export function selectPullRequests<T extends PullRequestCursorItem>(
|
|
484
|
+
items: T[],
|
|
485
|
+
prHeads: Record<string, string>,
|
|
486
|
+
): { toEmit: T[]; nextPrHeads: Record<string, string> } {
|
|
487
|
+
const toEmit = items.filter((it) => prHeads[String(it.number)] !== it.headSha);
|
|
488
|
+
const nextPrHeads: Record<string, string> = {};
|
|
489
|
+
for (const it of items) nextPrHeads[String(it.number)] = it.headSha;
|
|
490
|
+
return { toEmit, nextPrHeads };
|
|
491
|
+
}
|