@mastra/github-signals 0.2.2 → 0.2.3

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/dist/index.js CHANGED
@@ -1,477 +1,577 @@
1
- import { randomUUID, createHash } from 'crypto';
2
- import { readFile } from 'fs/promises';
3
- import { homedir } from 'os';
4
- import { join } from 'path';
5
- import { promisify } from 'util';
6
- import { SignalProvider } from '@mastra/core/signals';
7
- import { createTool } from '@mastra/core/tools';
8
- import z from 'zod';
9
-
10
- // src/index.ts
11
- var _execFileAsync;
1
+ import { createHash, randomUUID } from "crypto";
2
+ import { readFile, stat } from "fs/promises";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { promisify } from "util";
6
+ import { SignalProvider } from "@mastra/core/signals";
7
+ import { createTool } from "@mastra/core/tools";
8
+ import z from "zod";
9
+ //#region src/index.ts
10
+ let _execFileAsync;
12
11
  async function execFileAsync(file, args, options) {
13
- if (!_execFileAsync) {
14
- const cp = await import('child_process');
15
- _execFileAsync = promisify(cp.execFile);
16
- }
17
- return _execFileAsync(file, args, options);
12
+ if (!_execFileAsync) _execFileAsync = promisify((await import("child_process")).execFile);
13
+ return _execFileAsync(file, args, options);
18
14
  }
19
- var GITHUB_SUBSCRIBE_PR_TAG = "github-subscribe-pr";
20
- var GITHUB_UNSUBSCRIBE_PR_TAG = "github-unsubscribe-pr";
21
- var GITHUB_SYNC_STATUS_TAG = "github-sync-status";
22
- var GITHUB_SIGNALS_METADATA_KEY = "githubSignals";
23
- var DEFAULT_AUTHORIZED_PERMISSIONS = ["admin", "maintain", "write"];
24
- var DEFAULT_AUTHORIZED_BOTS = ["coderabbitai[bot]", "devin-ai-integration[bot]"];
25
- var PERMISSION_CACHE_TTL_MS = 5 * 60 * 1e3;
26
- var AUTHOR_GATED_NOTIFICATION_KINDS = /* @__PURE__ */ new Set(["pull-request-activity", "pull-request-review-activity"]);
27
- var createGithubTool = createTool;
15
+ const GITHUB_SUBSCRIBE_PR_TAG = "github-subscribe-pr";
16
+ const GITHUB_UNSUBSCRIBE_PR_TAG = "github-unsubscribe-pr";
17
+ const GITHUB_SYNC_STATUS_TAG = "github-sync-status";
18
+ const GITHUB_SIGNALS_METADATA_KEY = "githubSignals";
19
+ const DEFAULT_AUTHORIZED_PERMISSIONS = [
20
+ "admin",
21
+ "maintain",
22
+ "write"
23
+ ];
24
+ const DEFAULT_AUTHORIZED_BOTS = ["coderabbitai[bot]", "devin-ai-integration[bot]"];
25
+ const PERMISSION_CACHE_TTL_MS = 300 * 1e3;
26
+ /** Notification kinds driven by comment/review activity that should be gated by author permission. */
27
+ const AUTHOR_GATED_NOTIFICATION_KINDS = /* @__PURE__ */ new Set(["pull-request-activity", "pull-request-review-activity"]);
28
+ const createGithubTool = createTool;
28
29
  function isPlainObject(value) {
29
- return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
31
  }
31
32
  function readString(value) {
32
- return typeof value === "string" && value.length > 0 ? value : void 0;
33
+ return typeof value === "string" && value.length > 0 ? value : void 0;
33
34
  }
34
35
  function readNumber(value) {
35
- if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
36
- if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
37
- return void 0;
36
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
37
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
38
38
  }
39
39
  function stableJson(value) {
40
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
41
- if (isPlainObject(value)) {
42
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
43
- }
44
- return JSON.stringify(value);
40
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
41
+ if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
42
+ return JSON.stringify(value);
45
43
  }
46
44
  function snapshotHash(value) {
47
- return createHash("sha256").update(stableJson(value)).digest("hex");
45
+ return createHash("sha256").update(stableJson(value)).digest("hex");
48
46
  }
49
47
  function resolveHomePath(path) {
50
- return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
48
+ return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
49
+ }
50
+ async function readDbPathFromGitcrawlConfig(configPath) {
51
+ try {
52
+ const config = await readFile(resolveHomePath(configPath), "utf8");
53
+ const match = /^\s*db_path\s*=\s*['\"]([^'\"]+)['\"]/m.exec(config);
54
+ if (match?.[1]) return resolveHomePath(match[1]);
55
+ } catch {}
51
56
  }
52
- async function getGitcrawlDbPath() {
53
- if (process.env.GITCRAWL_DB_PATH) return resolveHomePath(process.env.GITCRAWL_DB_PATH);
54
- const configPath = process.env.GITCRAWL_CONFIG_PATH ?? join(homedir(), ".config", "gitcrawl", "config.toml");
55
- try {
56
- const config = await readFile(resolveHomePath(configPath), "utf8");
57
- const match = /^\s*db_path\s*=\s*['\"]([^'\"]+)['\"]/m.exec(config);
58
- if (match?.[1]) return resolveHomePath(match[1]);
59
- } catch {
60
- }
61
- return join(homedir(), ".config", "gitcrawl", "gitcrawl.db");
57
+ async function fileExists(path) {
58
+ try {
59
+ await stat(path);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
62
64
  }
63
- async function queryGitcrawlDb(sql) {
64
- const dbPath = await getGitcrawlDbPath();
65
- const { stdout } = await execFileAsync("sqlite3", ["-json", dbPath, sql], { maxBuffer: 10 * 1024 * 1024 });
66
- return JSON.parse(stdout || "[]");
65
+ /**
66
+ * Directories gitcrawl uses for its config and database by default. Linux
67
+ * uses the XDG path, but macOS uses ~/Library/Application Support, so probing
68
+ * only ~/.config silently misses the database on macOS.
69
+ */
70
+ function gitcrawlDefaultDirs() {
71
+ return [join(homedir(), ".config", "gitcrawl"), ...process.platform === "darwin" ? [join(homedir(), "Library", "Application Support", "gitcrawl")] : []];
67
72
  }
68
73
  function sqlString(value) {
69
- return `'${value.replaceAll("'", "''")}'`;
74
+ return `'${value.replaceAll("'", "''")}'`;
70
75
  }
71
76
  function getSignalMetadata(message) {
72
- if (message.role !== "signal") return void 0;
73
- const signal = message.content.metadata?.signal;
74
- return isPlainObject(signal) ? signal : void 0;
77
+ if (message.role !== "signal") return void 0;
78
+ const signal = message.content.metadata?.signal;
79
+ return isPlainObject(signal) ? signal : void 0;
75
80
  }
76
81
  function getGithubMetadata(threadMetadata) {
77
- const mastra = isPlainObject(threadMetadata?.mastra) ? threadMetadata.mastra : {};
78
- const githubSignals = isPlainObject(mastra[GITHUB_SIGNALS_METADATA_KEY]) ? mastra[GITHUB_SIGNALS_METADATA_KEY] : {};
79
- const rawSubscriptions = Array.isArray(githubSignals.subscriptions) ? githubSignals.subscriptions : [];
80
- const subscriptions = [];
81
- for (const rawSubscription of rawSubscriptions) {
82
- if (!isPlainObject(rawSubscription)) continue;
83
- const owner = readString(rawSubscription.owner);
84
- const repo = readString(rawSubscription.repo);
85
- const number = readNumber(rawSubscription.number);
86
- const subscribedAt = readString(rawSubscription.subscribedAt);
87
- const updatedAt = readString(rawSubscription.updatedAt);
88
- const lastSubscribeSignalId = readString(rawSubscription.lastSubscribeSignalId);
89
- if (!owner || !repo || !number || !subscribedAt || !updatedAt || !lastSubscribeSignalId) continue;
90
- subscriptions.push({
91
- owner,
92
- repo,
93
- number,
94
- subscribedAt,
95
- updatedAt,
96
- lastSubscribeSignalId,
97
- ...readString(rawSubscription.lastSyncAt) ? { lastSyncAt: readString(rawSubscription.lastSyncAt) } : {},
98
- ...rawSubscription.lastSyncStatus === "success" || rawSubscription.lastSyncStatus === "error" || rawSubscription.lastSyncStatus === "skipped" ? { lastSyncStatus: rawSubscription.lastSyncStatus } : {},
99
- ...readString(rawSubscription.lastSyncError) ? { lastSyncError: readString(rawSubscription.lastSyncError) } : {},
100
- ...readString(rawSubscription.lastObservedGithubUpdatedAt) ? { lastObservedGithubUpdatedAt: readString(rawSubscription.lastObservedGithubUpdatedAt) } : {},
101
- ...readString(rawSubscription.lastObservedContentHash) ? { lastObservedContentHash: readString(rawSubscription.lastObservedContentHash) } : {},
102
- ...readString(rawSubscription.lastObservedThreadContentHash) ? { lastObservedThreadContentHash: readString(rawSubscription.lastObservedThreadContentHash) } : {},
103
- ...readString(rawSubscription.lastObservedHeadSha) ? { lastObservedHeadSha: readString(rawSubscription.lastObservedHeadSha) } : {},
104
- ...readString(rawSubscription.lastObservedState) ? { lastObservedState: readString(rawSubscription.lastObservedState) } : {},
105
- ...readString(rawSubscription.lastObservedMergeableState) ? { lastObservedMergeableState: readString(rawSubscription.lastObservedMergeableState) } : {},
106
- ...readString(rawSubscription.lastObservedCiState) ? { lastObservedCiState: readString(rawSubscription.lastObservedCiState) } : {},
107
- ...readString(rawSubscription.lastObservedReviewStateHash) ? { lastObservedReviewStateHash: readString(rawSubscription.lastObservedReviewStateHash) } : {},
108
- ...readString(rawSubscription.lastNotificationAt) ? { lastNotificationAt: readString(rawSubscription.lastNotificationAt) } : {},
109
- ...readString(rawSubscription.lastNotificationKind) ? { lastNotificationKind: readString(rawSubscription.lastNotificationKind) } : {},
110
- ...rawSubscription.lastNotificationPriority === "medium" || rawSubscription.lastNotificationPriority === "high" ? { lastNotificationPriority: rawSubscription.lastNotificationPriority } : {},
111
- ...readString(rawSubscription.lastNotificationSummary) ? { lastNotificationSummary: readString(rawSubscription.lastNotificationSummary) } : {}
112
- });
113
- }
114
- return {
115
- subscriptions,
116
- ...githubSignals.subscriptionHintShown === true ? { subscriptionHintShown: true } : {}
117
- };
82
+ const mastra = isPlainObject(threadMetadata?.mastra) ? threadMetadata.mastra : {};
83
+ const githubSignals = isPlainObject(mastra["githubSignals"]) ? mastra[GITHUB_SIGNALS_METADATA_KEY] : {};
84
+ const rawSubscriptions = Array.isArray(githubSignals.subscriptions) ? githubSignals.subscriptions : [];
85
+ const subscriptions = [];
86
+ for (const rawSubscription of rawSubscriptions) {
87
+ if (!isPlainObject(rawSubscription)) continue;
88
+ const owner = readString(rawSubscription.owner);
89
+ const repo = readString(rawSubscription.repo);
90
+ const number = readNumber(rawSubscription.number);
91
+ const subscribedAt = readString(rawSubscription.subscribedAt);
92
+ const updatedAt = readString(rawSubscription.updatedAt);
93
+ const lastSubscribeSignalId = readString(rawSubscription.lastSubscribeSignalId);
94
+ if (!owner || !repo || !number || !subscribedAt || !updatedAt || !lastSubscribeSignalId) continue;
95
+ subscriptions.push({
96
+ owner,
97
+ repo,
98
+ number,
99
+ subscribedAt,
100
+ updatedAt,
101
+ lastSubscribeSignalId,
102
+ ...readString(rawSubscription.lastSyncAt) ? { lastSyncAt: readString(rawSubscription.lastSyncAt) } : {},
103
+ ...rawSubscription.lastSyncStatus === "success" || rawSubscription.lastSyncStatus === "error" || rawSubscription.lastSyncStatus === "skipped" ? { lastSyncStatus: rawSubscription.lastSyncStatus } : {},
104
+ ...readString(rawSubscription.lastSyncError) ? { lastSyncError: readString(rawSubscription.lastSyncError) } : {},
105
+ ...readString(rawSubscription.lastSnapshotError) ? { lastSnapshotError: readString(rawSubscription.lastSnapshotError) } : {},
106
+ ...readString(rawSubscription.lastObservedGithubUpdatedAt) ? { lastObservedGithubUpdatedAt: readString(rawSubscription.lastObservedGithubUpdatedAt) } : {},
107
+ ...readString(rawSubscription.lastObservedContentHash) ? { lastObservedContentHash: readString(rawSubscription.lastObservedContentHash) } : {},
108
+ ...readString(rawSubscription.lastObservedThreadContentHash) ? { lastObservedThreadContentHash: readString(rawSubscription.lastObservedThreadContentHash) } : {},
109
+ ...readString(rawSubscription.lastObservedHeadSha) ? { lastObservedHeadSha: readString(rawSubscription.lastObservedHeadSha) } : {},
110
+ ...readString(rawSubscription.lastObservedState) ? { lastObservedState: readString(rawSubscription.lastObservedState) } : {},
111
+ ...readString(rawSubscription.lastObservedMergeableState) ? { lastObservedMergeableState: readString(rawSubscription.lastObservedMergeableState) } : {},
112
+ ...readString(rawSubscription.lastObservedCiState) ? { lastObservedCiState: readString(rawSubscription.lastObservedCiState) } : {},
113
+ ...readString(rawSubscription.lastObservedReviewStateHash) ? { lastObservedReviewStateHash: readString(rawSubscription.lastObservedReviewStateHash) } : {},
114
+ ...readString(rawSubscription.lastNotificationAt) ? { lastNotificationAt: readString(rawSubscription.lastNotificationAt) } : {},
115
+ ...readString(rawSubscription.lastNotificationKind) ? { lastNotificationKind: readString(rawSubscription.lastNotificationKind) } : {},
116
+ ...rawSubscription.lastNotificationPriority === "medium" || rawSubscription.lastNotificationPriority === "high" ? { lastNotificationPriority: rawSubscription.lastNotificationPriority } : {},
117
+ ...readString(rawSubscription.lastNotificationSummary) ? { lastNotificationSummary: readString(rawSubscription.lastNotificationSummary) } : {}
118
+ });
119
+ }
120
+ return {
121
+ subscriptions,
122
+ ...githubSignals.subscriptionHintShown === true ? { subscriptionHintShown: true } : {}
123
+ };
118
124
  }
119
125
  function setGithubMetadata(threadMetadata, githubSignals) {
120
- const existing = threadMetadata ?? {};
121
- const mastra = isPlainObject(existing.mastra) ? existing.mastra : {};
122
- const existingGithubSignals = isPlainObject(mastra[GITHUB_SIGNALS_METADATA_KEY]) ? mastra[GITHUB_SIGNALS_METADATA_KEY] : {};
123
- return {
124
- ...existing,
125
- mastra: {
126
- ...mastra,
127
- [GITHUB_SIGNALS_METADATA_KEY]: {
128
- ...existingGithubSignals,
129
- ...githubSignals
130
- }
131
- }
132
- };
126
+ const existing = threadMetadata ?? {};
127
+ const mastra = isPlainObject(existing.mastra) ? existing.mastra : {};
128
+ const existingGithubSignals = isPlainObject(mastra["githubSignals"]) ? mastra[GITHUB_SIGNALS_METADATA_KEY] : {};
129
+ return {
130
+ ...existing,
131
+ mastra: {
132
+ ...mastra,
133
+ [GITHUB_SIGNALS_METADATA_KEY]: {
134
+ ...existingGithubSignals,
135
+ ...githubSignals
136
+ }
137
+ }
138
+ };
133
139
  }
134
140
  function getFailingChecks(snapshot) {
135
- return (snapshot.checks ?? []).filter((check) => check.conclusion === "failure" || check.conclusion === "timed_out");
141
+ return (snapshot.checks ?? []).filter((check) => check.conclusion === "failure" || check.conclusion === "timed_out");
136
142
  }
137
143
  function getPendingChecks(snapshot) {
138
- return (snapshot.checks ?? []).filter((check) => check.status && check.status !== "completed");
144
+ return (snapshot.checks ?? []).filter((check) => check.status && check.status !== "completed");
139
145
  }
140
146
  function getPrLabel(subscription, snapshot) {
141
- const pr = `${subscription.owner}/${subscription.repo}#${subscription.number}`;
142
- return snapshot?.title ? `${pr}: ${snapshot.title}` : pr;
147
+ const pr = `${subscription.owner}/${subscription.repo}#${subscription.number}`;
148
+ return snapshot?.title ? `${pr}: ${snapshot.title}` : pr;
143
149
  }
144
150
  function getMergedNotificationSummary(label) {
145
- return `${label} was merged. This thread has been automatically unsubscribed from this PR. Resubscribe if you still need updates.`;
151
+ return `${label} was merged. This thread has been automatically unsubscribed from this PR. Resubscribe if you still need updates.`;
146
152
  }
153
+ /**
154
+ * Removes a hidden block (its delimiters *and* its content) starting at every `open` marker.
155
+ *
156
+ * For each `open` occurrence the whole region up to and including the matching `close` is dropped.
157
+ * When `close` is missing the block is treated as unterminated and removed through end-of-string,
158
+ * so large payloads can't survive by omitting their closing marker. Matching is case-insensitive on
159
+ * the markers and uses plain `indexOf` scanning, so there is no regex backtracking (ReDoS-safe).
160
+ */
147
161
  function stripBlocks(text, open, close) {
148
- const haystack = text.toLowerCase();
149
- const openLower = open.toLowerCase();
150
- const closeLower = close.toLowerCase();
151
- let result = "";
152
- let cursor = 0;
153
- for (; ; ) {
154
- const start = haystack.indexOf(openLower, cursor);
155
- if (start === -1) {
156
- result += text.slice(cursor);
157
- return result;
158
- }
159
- result += text.slice(cursor, start);
160
- const end = haystack.indexOf(closeLower, start + openLower.length);
161
- if (end === -1) return result;
162
- cursor = end + closeLower.length;
163
- }
162
+ const haystack = text.toLowerCase();
163
+ const openLower = open.toLowerCase();
164
+ const closeLower = close.toLowerCase();
165
+ let result = "";
166
+ let cursor = 0;
167
+ for (;;) {
168
+ const start = haystack.indexOf(openLower, cursor);
169
+ if (start === -1) {
170
+ result += text.slice(cursor);
171
+ return result;
172
+ }
173
+ result += text.slice(cursor, start);
174
+ const end = haystack.indexOf(closeLower, start + openLower.length);
175
+ if (end === -1) return result;
176
+ cursor = end + closeLower.length;
177
+ }
164
178
  }
165
- var CODE_TOKEN_PREFIX = "\0CODE";
166
- var CODE_TOKEN_SUFFIX = "\0";
179
+ /** Sentinel marker wrapping a stashed Markdown code region; `\u0000` cannot appear in GitHub text. */
180
+ const CODE_TOKEN_PREFIX = "\0CODE";
181
+ const CODE_TOKEN_SUFFIX = "\0";
182
+ /**
183
+ * Temporarily removes Markdown code spans and fenced code blocks so tag stripping can't damage
184
+ * human-authored code examples.
185
+ *
186
+ * GitHub renders Markdown, so legitimate code like `` `<Component>` ``, generic type examples, or
187
+ * fenced JSX/TSX must survive sanitization. Each code region is replaced with an opaque token and
188
+ * pushed onto a stash; {@link restore} swaps the tokens back after the surrounding prose has been
189
+ * stripped of markup. Fenced blocks are matched before inline spans so backtick runs inside a fence
190
+ * are not mistaken for inline code.
191
+ */
167
192
  function preserveMarkdownCode(text) {
168
- const preserved = [];
169
- const stash = (match) => {
170
- const token = `${CODE_TOKEN_PREFIX}${preserved.length}${CODE_TOKEN_SUFFIX}`;
171
- preserved.push(match);
172
- return token;
173
- };
174
- const protectedText = text.replace(/```[\s\S]*?```/g, stash).replace(/(`{2,})(?!`)[\s\S]*?[^`]\1(?!`)/g, stash).replace(/`[^`\n]*`/g, stash);
175
- return {
176
- text: protectedText,
177
- restore: (sanitized) => sanitized.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => preserved[Number(index)] ?? "")
178
- };
193
+ const preserved = [];
194
+ const stash = (match) => {
195
+ const token = `${CODE_TOKEN_PREFIX}${preserved.length}${CODE_TOKEN_SUFFIX}`;
196
+ preserved.push(match);
197
+ return token;
198
+ };
199
+ return {
200
+ text: text.replace(/```[\s\S]*?```/g, stash).replace(/(`{2,})(?!`)[\s\S]*?[^`]\1(?!`)/g, stash).replace(/`[^`\n]*`/g, stash),
201
+ restore: (sanitized) => sanitized.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => preserved[Number(index)] ?? "")
202
+ };
179
203
  }
204
+ /**
205
+ * Removes XML/HTML-like markup — and the content it hides — from a PR comment body, leaving only
206
+ * human-readable text while preserving Markdown code examples.
207
+ *
208
+ * Review bots (e.g. CodeRabbit) embed large machine-only payloads in comments: base64 state blobs
209
+ * inside `<!-- ... -->` comments (often >100KB) and verbose collapsed `<details>` sections. Rather
210
+ * than targeting specific bot markers, we strip hidden blocks and tags generically, since none of
211
+ * that markup is useful to downstream consumers and persisting it balloons notification payloads and
212
+ * can overflow agent context windows.
213
+ *
214
+ * Markdown code spans/fenced blocks are stashed first and restored last, so legitimate code such as
215
+ * `` `<Component>` `` or fenced JSX is kept intact while bot markup elsewhere is removed. Block
216
+ * removal (comments, `<details>`) drops the *entire* section including its inner content, and any
217
+ * unterminated block is removed through end-of-string so a missing closing marker can't smuggle the
218
+ * payload through. All scanning is `indexOf`-based and the only regex used is a non-backtracking
219
+ * single-tag matcher, so adversarial input cannot trigger catastrophic backtracking (ReDoS). Any
220
+ * unterminated markup fragment (e.g. a dangling `<script` with no `>`) is dropped through end-of-
221
+ * string, and finally every remaining lone `<` is removed so no partial markup survives — while
222
+ * ordinary prose like `coverage < 80%` keeps its text intact.
223
+ */
180
224
  function sanitizeCommentText(body) {
181
- const { text: protectedBody, restore } = preserveMarkdownCode(body);
182
- let text = stripBlocks(protectedBody, "<!--", "-->");
183
- text = stripBlocks(text, "<details", "</details>");
184
- const stripped = text.replace(/<\/?[a-zA-Z][^<>]*>/g, "").replace(/<[!/a-zA-Z][\s\S]*$/g, "").replace(/</g, "").replace(/\n{3,}/g, "\n\n").trim();
185
- return restore(stripped);
225
+ const { text: protectedBody, restore } = preserveMarkdownCode(body);
226
+ let text = stripBlocks(protectedBody, "<!--", "-->");
227
+ text = stripBlocks(text, "<details", "</details>");
228
+ return restore(text.replace(/<\/?[a-zA-Z][^<>]*>/g, "").replace(/<[!/a-zA-Z][\s\S]*$/g, "").replace(/</g, "").replace(/\n{3,}/g, "\n\n").trim());
186
229
  }
230
+ /** Applies {@link sanitizeCommentText} to an optional comment body, preserving `undefined`. */
187
231
  function sanitizeCommentBody(body) {
188
- if (body === void 0) return void 0;
189
- const sanitized = sanitizeCommentText(body);
190
- return sanitized.length > 0 ? sanitized : void 0;
232
+ if (body === void 0) return void 0;
233
+ const sanitized = sanitizeCommentText(body);
234
+ return sanitized.length > 0 ? sanitized : void 0;
191
235
  }
192
236
  function getCommentExcerpt(body) {
193
- const excerpt = sanitizeCommentText(body).replace(/\s+/g, " ").trim();
194
- return excerpt.length > 240 ? `${excerpt.slice(0, 237)}...` : excerpt;
237
+ const excerpt = sanitizeCommentText(body).replace(/\s+/g, " ").trim();
238
+ return excerpt.length > 240 ? `${excerpt.slice(0, 237)}...` : excerpt;
195
239
  }
196
240
  function getCommentNotificationSummary(pr, snapshot) {
197
- if (!snapshot.latestCommentAuthor || !snapshot.latestCommentBody) return void 0;
198
- return `${snapshot.latestCommentAuthor} commented on ${pr}: ${getCommentExcerpt(snapshot.latestCommentBody)}`;
241
+ if (!snapshot.latestCommentAuthor || !snapshot.latestCommentBody) return void 0;
242
+ return `${snapshot.latestCommentAuthor} commented on ${pr}: ${getCommentExcerpt(snapshot.latestCommentBody)}`;
199
243
  }
200
- var githubActivityNotificationPriority = {
201
- high: 0,
202
- medium: 1
244
+ const githubActivityNotificationPriority = {
245
+ high: 0,
246
+ medium: 1
203
247
  };
204
248
  function getGithubActivityNotificationRank(notification) {
205
- return notification.kind === "pull-request-activity" ? 0 : 1;
249
+ return notification.kind === "pull-request-activity" ? 0 : 1;
206
250
  }
207
251
  function compareGithubActivityNotifications(a, b) {
208
- if (!a && !b) return 0;
209
- if (!a) return 1;
210
- if (!b) return -1;
211
- const priorityComparison = githubActivityNotificationPriority[a.priority] - githubActivityNotificationPriority[b.priority];
212
- if (priorityComparison !== 0) return priorityComparison;
213
- return getGithubActivityNotificationRank(a) - getGithubActivityNotificationRank(b);
252
+ if (!a && !b) return 0;
253
+ if (!a) return 1;
254
+ if (!b) return -1;
255
+ const priorityComparison = githubActivityNotificationPriority[a.priority] - githubActivityNotificationPriority[b.priority];
256
+ if (priorityComparison !== 0) return priorityComparison;
257
+ return getGithubActivityNotificationRank(a) - getGithubActivityNotificationRank(b);
214
258
  }
215
259
  function classifyGithubCommentActivityNotification(input) {
216
- if (isBotOnlyActivity(input.snapshot)) return void 0;
217
- const pr = `${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}`;
218
- const summary = getCommentNotificationSummary(pr, input.snapshot);
219
- if (!summary) return void 0;
220
- return { kind: "pull-request-activity", priority: "high", summary };
260
+ if (isBotOnlyActivity(input.snapshot)) return void 0;
261
+ const summary = getCommentNotificationSummary(`${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}`, input.snapshot);
262
+ if (!summary) return void 0;
263
+ return {
264
+ kind: "pull-request-activity",
265
+ priority: "high",
266
+ summary
267
+ };
221
268
  }
222
269
  function getCheckUpdatedTime(check) {
223
- const value = check.updatedAt ? Date.parse(check.updatedAt) : Number.NaN;
224
- return Number.isFinite(value) ? value : 0;
270
+ const value = check.updatedAt ? Date.parse(check.updatedAt) : NaN;
271
+ return Number.isFinite(value) ? value : 0;
225
272
  }
226
273
  function getCheckKey(check) {
227
- return `${check.name || "check"}:${check.detailsUrl || check.workflowName || ""}`;
274
+ return `${check.name || "check"}:${check.detailsUrl || check.workflowName || ""}`;
228
275
  }
229
276
  function normalizeGithubChecksForSnapshot(input) {
230
- const latestCheckUpdatedAt = input.checkRows.reduce(
231
- (latest, check) => Math.max(latest, getCheckUpdatedTime(check)),
232
- 0
233
- );
234
- const rows = [
235
- ...input.checkRows,
236
- ...input.workflowRows.filter(
237
- (workflow) => input.checkRows.length === 0 || getCheckUpdatedTime(workflow) >= latestCheckUpdatedAt
238
- )
239
- ];
240
- const byKey = /* @__PURE__ */ new Map();
241
- for (const row of rows) {
242
- const key = getCheckKey(row);
243
- const existing = byKey.get(key);
244
- if (!existing) {
245
- byKey.set(key, row);
246
- continue;
247
- }
248
- const rowTime = getCheckUpdatedTime(row);
249
- const existingTime = getCheckUpdatedTime(existing);
250
- if (rowTime > existingTime || rowTime === existingTime && existing.source === "workflow" && row.source === "check") {
251
- byKey.set(key, row);
252
- }
253
- }
254
- return [...byKey.values()].map(({ source: _source, ...check }) => check).sort((a, b) => `${a.name}:${a.detailsUrl ?? ""}`.localeCompare(`${b.name}:${b.detailsUrl ?? ""}`));
277
+ const latestCheckUpdatedAt = input.checkRows.reduce((latest, check) => Math.max(latest, getCheckUpdatedTime(check)), 0);
278
+ const rows = [...input.checkRows, ...input.workflowRows.filter((workflow) => input.checkRows.length === 0 || getCheckUpdatedTime(workflow) >= latestCheckUpdatedAt)];
279
+ const byKey = /* @__PURE__ */ new Map();
280
+ for (const row of rows) {
281
+ const key = getCheckKey(row);
282
+ const existing = byKey.get(key);
283
+ if (!existing) {
284
+ byKey.set(key, row);
285
+ continue;
286
+ }
287
+ const rowTime = getCheckUpdatedTime(row);
288
+ const existingTime = getCheckUpdatedTime(existing);
289
+ if (rowTime > existingTime || rowTime === existingTime && existing.source === "workflow" && row.source === "check") byKey.set(key, row);
290
+ }
291
+ return [...byKey.values()].map(({ source: _source, ...check }) => check).sort((a, b) => `${a.name}:${a.detailsUrl ?? ""}`.localeCompare(`${b.name}:${b.detailsUrl ?? ""}`));
255
292
  }
256
293
  function isBotOnlyActivity(snapshot) {
257
- return snapshot.latestCommentIsBot === true && (!snapshot.ciState || snapshot.ciState === "unknown");
294
+ return snapshot.latestCommentIsBot === true && (!snapshot.ciState || snapshot.ciState === "unknown");
258
295
  }
259
296
  function stringifyEvidence(value) {
260
- if (typeof value === "string") return value;
261
- try {
262
- return JSON.stringify(value);
263
- } catch {
264
- return "";
265
- }
297
+ if (typeof value === "string") return value;
298
+ try {
299
+ return JSON.stringify(value);
300
+ } catch {
301
+ return "";
302
+ }
266
303
  }
267
304
  function detectPrWorkEvidence(input) {
268
- const evidence = [
269
- input.text ?? "",
270
- ...(input.toolCalls ?? []).map((toolCall) => `${toolCall.toolName} ${stringifyEvidence(toolCall.args)}`)
271
- ].join("\n");
272
- if (!evidence.trim()) return void 0;
273
- const url = /github\.com\/([^\s/#]+)\/([^\s/#]+)\/pull\/(\d+)/i.exec(evidence);
274
- if (url?.[1] && url[2] && url[3]) return { owner: url[1], repo: url[2], number: Number(url[3]) };
275
- const repoRef = /\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)\b/.exec(evidence);
276
- if (repoRef?.[1] && repoRef[2] && repoRef[3])
277
- return { owner: repoRef[1], repo: repoRef[2], number: Number(repoRef[3]) };
278
- const ghCommand = /\bgh\s+(?:pr\s+(?:view|checks|status|comment|diff|checkout)|run\s+(?:rerun|view))\b/i.test(
279
- evidence
280
- );
281
- if (!ghCommand) return void 0;
282
- const numberMatch = /(?:^|\s)#?(\d{2,})(?:\s|$)/.exec(evidence);
283
- return numberMatch?.[1] ? { number: Number(numberMatch[1]) } : void 0;
305
+ const evidence = [input.text ?? "", ...(input.toolCalls ?? []).map((toolCall) => `${toolCall.toolName} ${stringifyEvidence(toolCall.args)}`)].join("\n");
306
+ if (!evidence.trim()) return void 0;
307
+ const url = /github\.com\/([^\s/#]+)\/([^\s/#]+)\/pull\/(\d+)/i.exec(evidence);
308
+ if (url?.[1] && url[2] && url[3]) return {
309
+ owner: url[1],
310
+ repo: url[2],
311
+ number: Number(url[3])
312
+ };
313
+ const repoRef = /\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)\b/.exec(evidence);
314
+ if (repoRef?.[1] && repoRef[2] && repoRef[3]) return {
315
+ owner: repoRef[1],
316
+ repo: repoRef[2],
317
+ number: Number(repoRef[3])
318
+ };
319
+ if (!/\bgh\s+(?:pr\s+(?:view|checks|status|comment|diff|checkout)|run\s+(?:rerun|view))\b/i.test(evidence)) return void 0;
320
+ const numberMatch = /(?:^|\s)#?(\d{2,})(?:\s|$)/.exec(evidence);
321
+ return numberMatch?.[1] ? { number: Number(numberMatch[1]) } : void 0;
284
322
  }
285
323
  function classifyGithubActivityNotification(input) {
286
- const pr = `${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}`;
287
- const label = getPrLabel(input.subscription, input.snapshot);
288
- if (input.snapshot.state && input.subscription.lastObservedState !== input.snapshot.state) {
289
- if (input.snapshot.state === "merged")
290
- return {
291
- kind: "pull-request-merged",
292
- priority: "high",
293
- summary: getMergedNotificationSummary(label)
294
- };
295
- if (input.snapshot.state === "closed")
296
- return { kind: "pull-request-closed", priority: "high", summary: `${label} was closed` };
297
- if (input.subscription.lastObservedState && input.snapshot.state === "open")
298
- return { kind: "pull-request-reopened", priority: "medium", summary: `${label} was reopened` };
299
- }
300
- const failingChecks = getFailingChecks(input.snapshot);
301
- if (input.snapshot.ciState === "failure" && input.subscription.lastObservedCiState !== "failure") {
302
- const names = failingChecks.slice(0, 3).map((check) => check.name).join(", ");
303
- return {
304
- kind: "pull-request-ci-failure",
305
- priority: "high",
306
- summary: `${pr} has failing CI${names ? `: ${names}` : ""}`
307
- };
308
- }
309
- if (input.snapshot.mergeableState === "dirty" && input.subscription.lastObservedMergeableState !== "dirty") {
310
- return {
311
- kind: "pull-request-conflict",
312
- priority: "high",
313
- summary: `${pr} has merge conflicts${input.snapshot.title ? `: ${input.snapshot.title}` : ""}`
314
- };
315
- }
316
- if (input.snapshot.mergeableState && input.subscription.lastObservedMergeableState === "dirty" && input.snapshot.mergeableState !== "dirty") {
317
- return {
318
- kind: "pull-request-conflict-resolved",
319
- priority: "medium",
320
- summary: `${pr} merge conflicts were resolved`
321
- };
322
- }
323
- if (input.snapshot.mergeableState === "dirty") return void 0;
324
- if (input.snapshot.ciState === "success" && input.subscription.lastObservedCiState && input.subscription.lastObservedCiState !== "success") {
325
- return { kind: "pull-request-ci-recovered", priority: "medium", summary: `${pr} CI recovered` };
326
- }
327
- if (input.snapshot.reviewStateHash && input.subscription.lastObservedReviewStateHash && input.snapshot.reviewStateHash !== input.subscription.lastObservedReviewStateHash && (input.snapshot.unresolvedReviewThreads ?? 0) > 0) {
328
- return {
329
- kind: "pull-request-review-activity",
330
- priority: "medium",
331
- summary: `${pr} has ${input.snapshot.unresolvedReviewThreads} unresolved review thread${input.snapshot.unresolvedReviewThreads === 1 ? "" : "s"}`
332
- };
333
- }
334
- const pendingChecks = getPendingChecks(input.snapshot);
335
- if (input.snapshot.ciState === "pending" && input.subscription.lastObservedCiState !== "pending" && pendingChecks.length > 0) {
336
- const names = pendingChecks.slice(0, 3).map((check) => check.name).join(", ");
337
- return {
338
- kind: "pull-request-ci-pending",
339
- priority: "medium",
340
- summary: `${pr} has CI still running${names ? `: ${names}` : ""}`
341
- };
342
- }
343
- if (input.snapshot.ciState === "pending" && input.subscription.lastObservedCiState === "pending") return void 0;
344
- if (isBotOnlyActivity(input.snapshot)) return void 0;
345
- const commentSummary = getCommentNotificationSummary(pr, input.snapshot);
346
- return {
347
- kind: "pull-request-activity",
348
- priority: commentSummary ? "high" : "medium",
349
- summary: commentSummary ?? `${pr} has new activity${input.snapshot.title ? `: ${input.snapshot.title}` : ""}`
350
- };
324
+ const pr = `${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}`;
325
+ const label = getPrLabel(input.subscription, input.snapshot);
326
+ if (input.snapshot.state && input.subscription.lastObservedState !== input.snapshot.state) {
327
+ if (input.snapshot.state === "merged") return {
328
+ kind: "pull-request-merged",
329
+ priority: "high",
330
+ summary: getMergedNotificationSummary(label)
331
+ };
332
+ if (input.snapshot.state === "closed") return {
333
+ kind: "pull-request-closed",
334
+ priority: "high",
335
+ summary: `${label} was closed`
336
+ };
337
+ if (input.subscription.lastObservedState && input.snapshot.state === "open") return {
338
+ kind: "pull-request-reopened",
339
+ priority: "medium",
340
+ summary: `${label} was reopened`
341
+ };
342
+ }
343
+ const failingChecks = getFailingChecks(input.snapshot);
344
+ if (input.snapshot.ciState === "failure" && input.subscription.lastObservedCiState !== "failure") {
345
+ const names = failingChecks.slice(0, 3).map((check) => check.name).join(", ");
346
+ return {
347
+ kind: "pull-request-ci-failure",
348
+ priority: "high",
349
+ summary: `${pr} has failing CI${names ? `: ${names}` : ""}`
350
+ };
351
+ }
352
+ if (input.snapshot.mergeableState === "dirty" && input.subscription.lastObservedMergeableState !== "dirty") return {
353
+ kind: "pull-request-conflict",
354
+ priority: "high",
355
+ summary: `${pr} has merge conflicts${input.snapshot.title ? `: ${input.snapshot.title}` : ""}`
356
+ };
357
+ if (input.snapshot.mergeableState && input.subscription.lastObservedMergeableState === "dirty" && input.snapshot.mergeableState !== "dirty") return {
358
+ kind: "pull-request-conflict-resolved",
359
+ priority: "medium",
360
+ summary: `${pr} merge conflicts were resolved`
361
+ };
362
+ if (input.snapshot.mergeableState === "dirty") return void 0;
363
+ if (input.snapshot.ciState === "success" && input.subscription.lastObservedCiState && input.subscription.lastObservedCiState !== "success") return {
364
+ kind: "pull-request-ci-recovered",
365
+ priority: "medium",
366
+ summary: `${pr} CI recovered`
367
+ };
368
+ if (input.snapshot.reviewStateHash && input.subscription.lastObservedReviewStateHash && input.snapshot.reviewStateHash !== input.subscription.lastObservedReviewStateHash && (input.snapshot.unresolvedReviewThreads ?? 0) > 0) return {
369
+ kind: "pull-request-review-activity",
370
+ priority: "medium",
371
+ summary: `${pr} has ${input.snapshot.unresolvedReviewThreads} unresolved review thread${input.snapshot.unresolvedReviewThreads === 1 ? "" : "s"}`
372
+ };
373
+ const pendingChecks = getPendingChecks(input.snapshot);
374
+ if (input.snapshot.ciState === "pending" && input.subscription.lastObservedCiState !== "pending" && pendingChecks.length > 0) {
375
+ const names = pendingChecks.slice(0, 3).map((check) => check.name).join(", ");
376
+ return {
377
+ kind: "pull-request-ci-pending",
378
+ priority: "medium",
379
+ summary: `${pr} has CI still running${names ? `: ${names}` : ""}`
380
+ };
381
+ }
382
+ if (input.snapshot.ciState === "pending" && input.subscription.lastObservedCiState === "pending") return void 0;
383
+ if (isBotOnlyActivity(input.snapshot)) return void 0;
384
+ const commentSummary = getCommentNotificationSummary(pr, input.snapshot);
385
+ return {
386
+ kind: "pull-request-activity",
387
+ priority: commentSummary ? "high" : "medium",
388
+ summary: commentSummary ?? `${pr} has new activity${input.snapshot.title ? `: ${input.snapshot.title}` : ""}`
389
+ };
351
390
  }
352
391
  function classifyGithubBaselineNotification(input) {
353
- const pr = `${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}`;
354
- const failingChecks = getFailingChecks(input.snapshot);
355
- const reviewCount = input.snapshot.unresolvedReviewThreads ?? 0;
356
- const high = input.snapshot.ciState === "failure" || input.snapshot.mergeableState === "dirty";
357
- const details = [
358
- input.snapshot.state ? `state: ${input.snapshot.state}` : void 0,
359
- input.snapshot.ciState && input.snapshot.ciState !== "unknown" ? `CI: ${input.snapshot.ciState}` : void 0,
360
- input.snapshot.mergeableState ? `mergeability: ${input.snapshot.mergeableState}` : void 0,
361
- reviewCount > 0 ? `${reviewCount} unresolved review thread${reviewCount === 1 ? "" : "s"}` : void 0,
362
- failingChecks.length > 0 ? `failing: ${failingChecks.slice(0, 3).map((check) => check.name).join(", ")}` : void 0
363
- ].filter(Boolean);
364
- return {
365
- kind: "pull-request-baseline",
366
- priority: high ? "high" : "medium",
367
- summary: `${pr} subscribed${input.snapshot.title ? `: ${input.snapshot.title}` : ""}${details.length ? ` (${details.join("; ")})` : ""}`
368
- };
392
+ const pr = `${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}`;
393
+ const failingChecks = getFailingChecks(input.snapshot);
394
+ const reviewCount = input.snapshot.unresolvedReviewThreads ?? 0;
395
+ const high = input.snapshot.ciState === "failure" || input.snapshot.mergeableState === "dirty";
396
+ const details = [
397
+ input.snapshot.state ? `state: ${input.snapshot.state}` : void 0,
398
+ input.snapshot.ciState && input.snapshot.ciState !== "unknown" ? `CI: ${input.snapshot.ciState}` : void 0,
399
+ input.snapshot.mergeableState ? `mergeability: ${input.snapshot.mergeableState}` : void 0,
400
+ reviewCount > 0 ? `${reviewCount} unresolved review thread${reviewCount === 1 ? "" : "s"}` : void 0,
401
+ failingChecks.length > 0 ? `failing: ${failingChecks.slice(0, 3).map((check) => check.name).join(", ")}` : void 0
402
+ ].filter(Boolean);
403
+ return {
404
+ kind: "pull-request-baseline",
405
+ priority: high ? "high" : "medium",
406
+ summary: `${pr} subscribed${input.snapshot.title ? `: ${input.snapshot.title}` : ""}${details.length ? ` (${details.join("; ")})` : ""}`
407
+ };
369
408
  }
370
409
  function applySnapshotCursor(subscription, snapshot) {
371
- if (snapshot.githubUpdatedAt) subscription.lastObservedGithubUpdatedAt = snapshot.githubUpdatedAt;
372
- if (snapshot.contentHash) subscription.lastObservedContentHash = snapshot.contentHash;
373
- if (snapshot.threadContentHash) subscription.lastObservedThreadContentHash = snapshot.threadContentHash;
374
- if (snapshot.headSha) subscription.lastObservedHeadSha = snapshot.headSha;
375
- if (snapshot.state) subscription.lastObservedState = snapshot.state;
376
- if (snapshot.mergeableState) subscription.lastObservedMergeableState = snapshot.mergeableState;
377
- if (snapshot.ciState) subscription.lastObservedCiState = snapshot.ciState;
378
- if (snapshot.reviewStateHash) subscription.lastObservedReviewStateHash = snapshot.reviewStateHash;
410
+ if (snapshot.githubUpdatedAt) subscription.lastObservedGithubUpdatedAt = snapshot.githubUpdatedAt;
411
+ if (snapshot.contentHash) subscription.lastObservedContentHash = snapshot.contentHash;
412
+ if (snapshot.threadContentHash) subscription.lastObservedThreadContentHash = snapshot.threadContentHash;
413
+ if (snapshot.headSha) subscription.lastObservedHeadSha = snapshot.headSha;
414
+ if (snapshot.state) subscription.lastObservedState = snapshot.state;
415
+ if (snapshot.mergeableState) subscription.lastObservedMergeableState = snapshot.mergeableState;
416
+ if (snapshot.ciState) subscription.lastObservedCiState = snapshot.ciState;
417
+ if (snapshot.reviewStateHash) subscription.lastObservedReviewStateHash = snapshot.reviewStateHash;
379
418
  }
380
419
  function parseGitHubRemoteUrl(remoteUrl) {
381
- const trimmed = remoteUrl.trim().replace(/\.git$/, "");
382
- const httpsMatch = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/.exec(trimmed);
383
- if (httpsMatch?.[1] && httpsMatch[2]) return { owner: httpsMatch[1], repo: httpsMatch[2] };
384
- const sshMatch = /^git@github\.com:([^/]+)\/([^/]+)$/.exec(trimmed);
385
- if (sshMatch?.[1] && sshMatch[2]) return { owner: sshMatch[1], repo: sshMatch[2] };
386
- return void 0;
420
+ const trimmed = remoteUrl.trim().replace(/\.git$/, "");
421
+ const httpsMatch = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/.exec(trimmed);
422
+ if (httpsMatch?.[1] && httpsMatch[2]) return {
423
+ owner: httpsMatch[1],
424
+ repo: httpsMatch[2]
425
+ };
426
+ const sshMatch = /^git@github\.com:([^/]+)\/([^/]+)$/.exec(trimmed);
427
+ if (sshMatch?.[1] && sshMatch[2]) return {
428
+ owner: sshMatch[1],
429
+ repo: sshMatch[2]
430
+ };
387
431
  }
388
432
  var GitRemoteRepositoryResolver = class {
389
- async resolveRepository(input) {
390
- try {
391
- const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
392
- cwd: input.cwd,
393
- signal: input.abortSignal
394
- });
395
- return parseGitHubRemoteUrl(stdout);
396
- } catch {
397
- return void 0;
398
- }
399
- }
433
+ async resolveRepository(input) {
434
+ try {
435
+ const { stdout } = await execFileAsync("git", [
436
+ "remote",
437
+ "get-url",
438
+ "origin"
439
+ ], {
440
+ cwd: input.cwd,
441
+ signal: input.abortSignal
442
+ });
443
+ return parseGitHubRemoteUrl(stdout);
444
+ } catch {
445
+ return;
446
+ }
447
+ }
400
448
  };
401
449
  var GitcrawlSyncClient = class {
402
- #command;
403
- constructor(options = {}) {
404
- this.#command = options.command ?? "gitcrawl";
405
- }
406
- async syncPullRequest(input) {
407
- try {
408
- const args = [
409
- "sync",
410
- `${input.owner}/${input.repo}`,
411
- "--numbers",
412
- String(input.number),
413
- ...input.includeComments === false ? [] : ["--include-comments"],
414
- "--with",
415
- "pr-details",
416
- "--json"
417
- ];
418
- const { stdout, stderr } = await execFileAsync(this.#command, args, {
419
- cwd: input.cwd,
420
- signal: input.abortSignal,
421
- maxBuffer: 10 * 1024 * 1024
422
- });
423
- return { ok: true, stdout, stderr };
424
- } catch (error) {
425
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
426
- }
427
- }
428
- async getPullRequestSnapshot(input) {
429
- try {
430
- const { stdout } = await execFileAsync(
431
- this.#command,
432
- ["threads", `${input.owner}/${input.repo}`, "--numbers", String(input.number), "--json"],
433
- {
434
- cwd: input.cwd,
435
- signal: input.abortSignal,
436
- maxBuffer: 10 * 1024 * 1024
437
- }
438
- );
439
- const parsed = JSON.parse(stdout);
440
- const thread = parsed.threads?.find((item) => readNumber(item.number) === input.number);
441
- if (!thread) return void 0;
442
- const owner = sqlString(input.owner);
443
- const repo = sqlString(input.repo);
444
- const number = input.number;
445
- const [threadDetails] = await queryGitcrawlDb(`select t.state, t.closed_at_gh, t.merged_at_gh
450
+ #command;
451
+ #dbPathPromise;
452
+ constructor(options = {}) {
453
+ this.#command = options.command ?? "gitcrawl";
454
+ }
455
+ /**
456
+ * Resolve the gitcrawl SQLite database path. Explicit env overrides win,
457
+ * then gitcrawl itself is asked (authoritative across platforms and
458
+ * versions), then known default locations are probed.
459
+ */
460
+ async #resolveDbPath() {
461
+ if (process.env.GITCRAWL_DB_PATH) return resolveHomePath(process.env.GITCRAWL_DB_PATH);
462
+ if (process.env.GITCRAWL_CONFIG_PATH) {
463
+ const fromEnvConfig = await readDbPathFromGitcrawlConfig(process.env.GITCRAWL_CONFIG_PATH);
464
+ if (fromEnvConfig) return fromEnvConfig;
465
+ }
466
+ try {
467
+ const { stdout } = await execFileAsync(this.#command, ["status", "--json"], { maxBuffer: 10 * 1024 * 1024 });
468
+ const status = JSON.parse(stdout);
469
+ const reported = readString(status.database_path) ?? readString(status.db_path);
470
+ if (reported) return resolveHomePath(reported);
471
+ } catch {}
472
+ const defaultDirs = gitcrawlDefaultDirs();
473
+ for (const dir of defaultDirs) {
474
+ const fromConfig = await readDbPathFromGitcrawlConfig(join(dir, "config.toml"));
475
+ if (fromConfig) return fromConfig;
476
+ }
477
+ for (const dir of defaultDirs) {
478
+ const candidate = join(dir, "gitcrawl.db");
479
+ if (await fileExists(candidate)) return candidate;
480
+ }
481
+ return join(defaultDirs[0], "gitcrawl.db");
482
+ }
483
+ async #queryDb(sql) {
484
+ this.#dbPathPromise ??= this.#resolveDbPath();
485
+ const dbPath = await this.#dbPathPromise;
486
+ try {
487
+ const { stdout } = await execFileAsync("sqlite3", [
488
+ "-json",
489
+ dbPath,
490
+ sql
491
+ ], { maxBuffer: 10 * 1024 * 1024 });
492
+ return JSON.parse(stdout || "[]");
493
+ } catch (error) {
494
+ this.#dbPathPromise = void 0;
495
+ const message = error instanceof Error ? error.message : String(error);
496
+ throw new Error(`gitcrawl database query failed (db: ${dbPath}): ${message}`);
497
+ }
498
+ }
499
+ async syncPullRequest(input) {
500
+ try {
501
+ const args = [
502
+ "sync",
503
+ `${input.owner}/${input.repo}`,
504
+ "--numbers",
505
+ String(input.number),
506
+ ...input.includeComments === false ? [] : ["--include-comments"],
507
+ "--with",
508
+ "pr-details",
509
+ "--json"
510
+ ];
511
+ const { stdout, stderr } = await execFileAsync(this.#command, args, {
512
+ cwd: input.cwd,
513
+ signal: input.abortSignal,
514
+ maxBuffer: 10 * 1024 * 1024
515
+ });
516
+ return {
517
+ ok: true,
518
+ stdout,
519
+ stderr
520
+ };
521
+ } catch (error) {
522
+ return {
523
+ ok: false,
524
+ error: error instanceof Error ? error.message : String(error)
525
+ };
526
+ }
527
+ }
528
+ async getPullRequestSnapshot(input) {
529
+ const { stdout } = await execFileAsync(this.#command, [
530
+ "threads",
531
+ `${input.owner}/${input.repo}`,
532
+ "--numbers",
533
+ String(input.number),
534
+ "--json"
535
+ ], {
536
+ cwd: input.cwd,
537
+ signal: input.abortSignal,
538
+ maxBuffer: 10 * 1024 * 1024
539
+ });
540
+ const thread = JSON.parse(stdout).threads?.find((item) => readNumber(item.number) === input.number);
541
+ if (!thread) return void 0;
542
+ const owner = sqlString(input.owner);
543
+ const repo = sqlString(input.repo);
544
+ const number = input.number;
545
+ const [threadDetails] = await this.#queryDb(`select t.state, t.closed_at_gh, t.merged_at_gh
446
546
  from threads t
447
547
  join repositories r on r.id=t.repo_id
448
548
  where r.owner=${owner} and r.name=${repo} and t.number=${number}
449
549
  limit 1`);
450
- const [details] = await queryGitcrawlDb(`select d.head_sha, d.head_ref, d.mergeable_state,
550
+ const [details] = await this.#queryDb(`select d.head_sha, d.head_ref, d.mergeable_state,
451
551
  json_extract(d.raw_json, '$.merged_at') as merged_at
452
552
  from pull_request_details d
453
553
  join threads t on t.id=d.thread_id
454
554
  join repositories r on r.id=t.repo_id
455
555
  where r.owner=${owner} and r.name=${repo} and t.number=${number}
456
556
  limit 1`);
457
- const headSha = readString(details?.head_sha);
458
- const checkRows = await queryGitcrawlDb(`select c.name, c.status, c.conclusion, c.workflow_name, c.details_url,
557
+ const headSha = readString(details?.head_sha);
558
+ const checkRows = await this.#queryDb(`select c.name, c.status, c.conclusion, c.workflow_name, c.details_url,
459
559
  coalesce(c.completed_at, c.started_at, c.fetched_at) as updated_at
460
560
  from pull_request_checks c
461
561
  join threads t on t.id=c.thread_id
462
562
  join repositories r on r.id=t.repo_id
463
563
  where r.owner=${owner} and r.name=${repo} and t.number=${number}${headSha ? ` and json_extract(c.raw_json, '$.head_sha')=${sqlString(headSha)}` : ""}`);
464
- const workflowRows = details?.head_sha ? await queryGitcrawlDb(`select workflow_name, status, conclusion, html_url, updated_at_gh
564
+ const workflowRows = details?.head_sha ? await this.#queryDb(`select workflow_name, status, conclusion, html_url, updated_at_gh
465
565
  from github_workflow_runs w
466
566
  join repositories r on r.id=w.repo_id
467
567
  where r.owner=${owner} and r.name=${repo} and w.head_sha=${sqlString(details.head_sha)}`) : [];
468
- const [reviewState] = await queryGitcrawlDb(`select count(*) as unresolved_count,
568
+ const [reviewState] = await this.#queryDb(`select count(*) as unresolved_count,
469
569
  max(coalesce(first_comment_updated_at, first_comment_created_at, fetched_at)) as latest_review_thread_at
470
570
  from pull_request_review_threads rt
471
571
  join threads t on t.id=rt.thread_id
472
572
  join repositories r on r.id=t.repo_id
473
573
  where r.owner=${owner} and r.name=${repo} and t.number=${number} and rt.is_resolved=0`);
474
- const latestComments = await queryGitcrawlDb(`select c.author_login, c.author_type, c.is_bot, c.body, json_extract(c.raw_json, '$.html_url') as html_url,
574
+ const latestComments = await this.#queryDb(`select c.author_login, c.author_type, c.is_bot, c.body, json_extract(c.raw_json, '$.html_url') as html_url,
475
575
  coalesce(c.updated_at_gh, c.created_at_gh) as updated_at
476
576
  from comments c
477
577
  join threads t on t.id=c.thread_id
@@ -479,893 +579,927 @@ var GitcrawlSyncClient = class {
479
579
  where r.owner=${owner} and r.name=${repo} and t.number=${number}
480
580
  order by coalesce(c.updated_at_gh, c.created_at_gh) desc
481
581
  limit 20`);
482
- const latestComment = latestComments[0];
483
- const checks = normalizeGithubChecksForSnapshot({
484
- checkRows: checkRows.map((row) => ({
485
- source: "check",
486
- name: readString(row.name) ?? "check",
487
- status: readString(row.status),
488
- conclusion: readString(row.conclusion),
489
- workflowName: readString(row.workflow_name),
490
- detailsUrl: readString(row.details_url),
491
- updatedAt: readString(row.updated_at)
492
- })),
493
- workflowRows: workflowRows.map((row) => ({
494
- source: "workflow",
495
- name: readString(row.workflow_name) ?? "workflow",
496
- status: readString(row.status),
497
- conclusion: readString(row.conclusion),
498
- workflowName: readString(row.workflow_name),
499
- detailsUrl: readString(row.html_url),
500
- updatedAt: readString(row.updated_at_gh)
501
- }))
502
- });
503
- const ciState = checks.some((check) => check.conclusion === "failure" || check.conclusion === "timed_out") ? "failure" : checks.some((check) => check.status && check.status !== "completed") ? "pending" : checks.length > 0 ? "success" : "unknown";
504
- const threadContentHash = readString(thread.content_hash);
505
- const unresolvedReviewThreads = Number(reviewState?.unresolved_count ?? 0);
506
- const reviewStateHash = snapshotHash({
507
- unresolvedReviewThreads,
508
- latestReviewThreadAt: reviewState?.latest_review_thread_at
509
- });
510
- const contentHash = snapshotHash({
511
- threadContentHash,
512
- state: thread.state,
513
- headSha: details?.head_sha,
514
- mergeableState: details?.mergeable_state,
515
- ciState,
516
- reviewStateHash,
517
- checks: checks.map((check) => ({
518
- name: check.name,
519
- status: check.status,
520
- conclusion: check.conclusion,
521
- detailsUrl: check.detailsUrl,
522
- updatedAt: check.updatedAt
523
- }))
524
- });
525
- return {
526
- title: readString(thread.title),
527
- state: readString(details?.merged_at) || readString(threadDetails?.merged_at_gh) ? "merged" : readString(threadDetails?.state) ?? readString(thread.state),
528
- htmlUrl: readString(thread.html_url),
529
- githubUpdatedAt: readString(thread.updated_at_gh),
530
- closedAt: readString(threadDetails?.closed_at_gh),
531
- mergedAt: readString(details?.merged_at) ?? readString(threadDetails?.merged_at_gh),
532
- threadContentHash,
533
- contentHash,
534
- headSha: readString(details?.head_sha),
535
- headRef: readString(details?.head_ref),
536
- mergeableState: readString(details?.mergeable_state),
537
- checks,
538
- ciState,
539
- unresolvedReviewThreads,
540
- reviewStateHash,
541
- latestReviewThreadAt: readString(reviewState?.latest_review_thread_at),
542
- latestCommentAuthor: readString(latestComment?.author_login),
543
- latestCommentAuthorType: readString(latestComment?.author_type),
544
- latestCommentIsBot: latestComment?.is_bot === 1,
545
- latestCommentBody: sanitizeCommentBody(readString(latestComment?.body)),
546
- latestCommentUrl: readString(latestComment?.html_url),
547
- latestCommentUpdatedAt: readString(latestComment?.updated_at),
548
- latestComments: latestComments.map((comment) => ({
549
- author: readString(comment.author_login),
550
- authorType: readString(comment.author_type),
551
- isBot: comment.is_bot === 1,
552
- body: sanitizeCommentBody(readString(comment.body)),
553
- url: readString(comment.html_url),
554
- updatedAt: readString(comment.updated_at)
555
- }))
556
- };
557
- } catch {
558
- return void 0;
559
- }
560
- }
582
+ const latestComment = latestComments[0];
583
+ const checks = normalizeGithubChecksForSnapshot({
584
+ checkRows: checkRows.map((row) => ({
585
+ source: "check",
586
+ name: readString(row.name) ?? "check",
587
+ status: readString(row.status),
588
+ conclusion: readString(row.conclusion),
589
+ workflowName: readString(row.workflow_name),
590
+ detailsUrl: readString(row.details_url),
591
+ updatedAt: readString(row.updated_at)
592
+ })),
593
+ workflowRows: workflowRows.map((row) => ({
594
+ source: "workflow",
595
+ name: readString(row.workflow_name) ?? "workflow",
596
+ status: readString(row.status),
597
+ conclusion: readString(row.conclusion),
598
+ workflowName: readString(row.workflow_name),
599
+ detailsUrl: readString(row.html_url),
600
+ updatedAt: readString(row.updated_at_gh)
601
+ }))
602
+ });
603
+ const ciState = checks.some((check) => check.conclusion === "failure" || check.conclusion === "timed_out") ? "failure" : checks.some((check) => check.status && check.status !== "completed") ? "pending" : checks.length > 0 ? "success" : "unknown";
604
+ const threadContentHash = readString(thread.content_hash);
605
+ const unresolvedReviewThreads = Number(reviewState?.unresolved_count ?? 0);
606
+ const reviewStateHash = snapshotHash({
607
+ unresolvedReviewThreads,
608
+ latestReviewThreadAt: reviewState?.latest_review_thread_at
609
+ });
610
+ const contentHash = snapshotHash({
611
+ threadContentHash,
612
+ state: thread.state,
613
+ headSha: details?.head_sha,
614
+ mergeableState: details?.mergeable_state,
615
+ ciState,
616
+ reviewStateHash,
617
+ checks: checks.map((check) => ({
618
+ name: check.name,
619
+ status: check.status,
620
+ conclusion: check.conclusion,
621
+ detailsUrl: check.detailsUrl,
622
+ updatedAt: check.updatedAt
623
+ }))
624
+ });
625
+ return {
626
+ title: readString(thread.title),
627
+ state: readString(details?.merged_at) || readString(threadDetails?.merged_at_gh) ? "merged" : readString(threadDetails?.state) ?? readString(thread.state),
628
+ htmlUrl: readString(thread.html_url),
629
+ githubUpdatedAt: readString(thread.updated_at_gh),
630
+ closedAt: readString(threadDetails?.closed_at_gh),
631
+ mergedAt: readString(details?.merged_at) ?? readString(threadDetails?.merged_at_gh),
632
+ threadContentHash,
633
+ contentHash,
634
+ headSha: readString(details?.head_sha),
635
+ headRef: readString(details?.head_ref),
636
+ mergeableState: readString(details?.mergeable_state),
637
+ checks,
638
+ ciState,
639
+ unresolvedReviewThreads,
640
+ reviewStateHash,
641
+ latestReviewThreadAt: readString(reviewState?.latest_review_thread_at),
642
+ latestCommentAuthor: readString(latestComment?.author_login),
643
+ latestCommentAuthorType: readString(latestComment?.author_type),
644
+ latestCommentIsBot: latestComment?.is_bot === 1,
645
+ latestCommentBody: sanitizeCommentBody(readString(latestComment?.body)),
646
+ latestCommentUrl: readString(latestComment?.html_url),
647
+ latestCommentUpdatedAt: readString(latestComment?.updated_at),
648
+ latestComments: latestComments.map((comment) => ({
649
+ author: readString(comment.author_login),
650
+ authorType: readString(comment.author_type),
651
+ isBot: comment.is_bot === 1,
652
+ body: sanitizeCommentBody(readString(comment.body)),
653
+ url: readString(comment.html_url),
654
+ updatedAt: readString(comment.updated_at)
655
+ }))
656
+ };
657
+ }
561
658
  };
562
659
  var GithubSignals = class extends SignalProvider {
563
- id = "github-signals";
564
- name = "GitHub Signals";
565
- #ghMastra;
566
- static signals = {
567
- subscribeToPR(input) {
568
- const normalized = typeof input === "number" ? { number: input } : input;
569
- return {
570
- type: "reactive",
571
- tagName: GITHUB_SUBSCRIBE_PR_TAG,
572
- contents: `Subscribe to GitHub PR #${normalized.number}`,
573
- attributes: {
574
- ...normalized.owner ? { owner: normalized.owner } : {},
575
- ...normalized.repo ? { repo: normalized.repo } : {},
576
- number: normalized.number
577
- },
578
- metadata: {
579
- github: {
580
- action: "subscribeToPR",
581
- ...normalized
582
- }
583
- }
584
- };
585
- },
586
- unsubscribeFromPR(input) {
587
- const normalized = typeof input === "number" ? { number: input } : input;
588
- return {
589
- type: "reactive",
590
- tagName: GITHUB_UNSUBSCRIBE_PR_TAG,
591
- contents: `Unsubscribe from GitHub PR #${normalized.number}`,
592
- attributes: {
593
- ...normalized.owner ? { owner: normalized.owner } : {},
594
- ...normalized.repo ? { repo: normalized.repo } : {},
595
- number: normalized.number
596
- },
597
- metadata: {
598
- github: {
599
- action: "unsubscribeFromPR",
600
- ...normalized
601
- }
602
- }
603
- };
604
- }
605
- };
606
- #options;
607
- #syncClient;
608
- #repositoryResolver;
609
- #polling = /* @__PURE__ */ new Map();
610
- #permissionCache = /* @__PURE__ */ new Map();
611
- #agent;
612
- #agentOptions = {};
613
- #subscriptionsChangedHandler;
614
- #pollingChangedHandler;
615
- constructor(options = {}) {
616
- super();
617
- this.#options = options;
618
- this.#syncClient = options.syncClient ?? new GitcrawlSyncClient({ command: options.gitcrawlCommand });
619
- this.#repositoryResolver = options.repositoryResolver ?? new GitRemoteRepositoryResolver();
620
- if (options.getNotificationStreamOptions) {
621
- this.#agentOptions = { getNotificationStreamOptions: options.getNotificationStreamOptions };
622
- }
623
- }
624
- /**
625
- * @deprecated Use `Agent({ signals: [githubSignals] })` instead.
626
- * Kept for backward compatibility.
627
- */
628
- addAgent(agent, options = {}) {
629
- this.#agent = agent;
630
- this.#agentOptions = options;
631
- }
632
- /**
633
- * Called by the Agent constructor when this provider is passed via `signals: [...]`.
634
- * Sets the bidirectional link so the provider can send signals back to the agent.
635
- */
636
- connect(agent) {
637
- super.connect(agent);
638
- this.#agent = agent;
639
- }
640
- getInputProcessors() {
641
- return [this];
642
- }
643
- getOutputProcessors() {
644
- return [this];
645
- }
646
- onSubscriptionsChanged(handler) {
647
- this.#subscriptionsChangedHandler = handler;
648
- }
649
- onPollingChanged(handler) {
650
- this.#pollingChangedHandler = handler;
651
- }
652
- __registerMastra(mastra) {
653
- super.__registerMastra(mastra);
654
- this.#ghMastra = mastra;
655
- }
656
- async syncThreadNow(input) {
657
- return this.#pollThread(input, { includeComments: true });
658
- }
659
- async subscribeThreadToPR(input) {
660
- const pr = typeof input.pr === "number" ? { number: input.pr } : input.pr;
661
- return this.#subscribe({
662
- id: `github-command-subscribe-${randomUUID()}`,
663
- ...pr,
664
- threadId: input.threadId,
665
- resourceId: input.resourceId
666
- });
667
- }
668
- async unsubscribeThreadFromPR(input) {
669
- const pr = typeof input.pr === "number" ? { number: input.pr } : input.pr;
670
- return this.#unsubscribe({
671
- id: `github-command-unsubscribe-${randomUUID()}`,
672
- ...pr,
673
- threadId: input.threadId,
674
- resourceId: input.resourceId
675
- });
676
- }
677
- async startPollingForThread(input, options = {}) {
678
- const subscriptions = await this.#getThreadSubscriptions(input);
679
- if (subscriptions.length === 0) {
680
- this.stopPollingForThread(input);
681
- return false;
682
- }
683
- const key = this.#pollingKey(input);
684
- for (const [pollingKey, state] of this.#polling.entries()) {
685
- if (pollingKey === key) continue;
686
- clearInterval(state.timer);
687
- this.#polling.delete(pollingKey);
688
- }
689
- if (this.#polling.has(key)) return true;
690
- const runPoll = (pollOptions = {}) => {
691
- void this.#pollThread(input, pollOptions).catch((error) => {
692
- console.warn("GitHub PR polling failed:", error);
693
- });
694
- };
695
- const timer = setInterval(() => {
696
- runPoll({ includeComments: true });
697
- }, this.#options.pollIntervalMs ?? 3e5);
698
- if (options.pollImmediately) runPoll({ includeComments: true });
699
- timer.unref?.();
700
- this.#polling.set(key, { ...input, timer, running: false });
701
- return true;
702
- }
703
- stopPollingForThread(input) {
704
- const key = this.#pollingKey(input);
705
- const state = this.#polling.get(key);
706
- if (!state) return;
707
- clearInterval(state.timer);
708
- this.#polling.delete(key);
709
- }
710
- isPollingThread(input) {
711
- return this.#polling.has(this.#pollingKey(input));
712
- }
713
- isPollingThreadRunning(input) {
714
- return this.#polling.get(this.#pollingKey(input))?.running ?? false;
715
- }
716
- getPollIntervalMs() {
717
- return this.#options.pollIntervalMs ?? 3e5;
718
- }
719
- stopAllPolling() {
720
- for (const state of this.#polling.values()) clearInterval(state.timer);
721
- this.#polling.clear();
722
- }
723
- async pollThreadNow(input) {
724
- return this.#pollThread(input, { includeComments: true });
725
- }
726
- async processInputStep(args) {
727
- const tools = this.#createTools(args);
728
- if (args.stepNumber !== 0) return { tools };
729
- const signal = this.#findLatestGithubSignal(args.messages);
730
- if (!signal) return { tools };
731
- const threadContext = this.#getThreadContext(args);
732
- if (signal.tagName === GITHUB_UNSUBSCRIBE_PR_TAG) {
733
- const result2 = await this.#unsubscribe({ ...signal, ...threadContext, abortSignal: args.abortSignal });
734
- await this.#sendStatus(args, result2, {
735
- status: result2.removed ? "unsubscribed" : "not_subscribed",
736
- action: "unsubscribeFromPR",
737
- message: result2.removed ? `Unsubscribed from ${result2.owner}/${result2.repo}#${result2.number}.` : `No GitHub subscription found for ${result2.owner}/${result2.repo}#${result2.number}.`
738
- });
739
- return { tools };
740
- }
741
- const result = await this.#subscribe({ ...signal, ...threadContext, abortSignal: args.abortSignal });
742
- if (result.alreadyProcessed) return { tools };
743
- await this.#sendStatus(args, result, {
744
- status: result.syncResult?.ok === false ? "sync_error" : "subscribed",
745
- action: "subscribeToPR",
746
- message: result.syncResult?.ok === false ? `Subscribed to ${result.owner}/${result.repo}#${result.number}, but gitcrawl sync failed: ${result.syncResult.error}` : `Subscribed to ${result.owner}/${result.repo}#${result.number}.`
747
- });
748
- return { tools };
749
- }
750
- async processOutputStep(args) {
751
- const evidence = detectPrWorkEvidence({ text: args.text, toolCalls: args.toolCalls });
752
- if (!evidence) return;
753
- const threadContext = this.#getThreadContext(args);
754
- if (!threadContext.threadId || !threadContext.resourceId) return;
755
- const { threadStore, loadedThread } = await this.#loadThread(threadContext);
756
- const githubMetadata = getGithubMetadata(loadedThread.metadata);
757
- if (githubMetadata.subscriptionHintShown || githubMetadata.subscriptions.length > 0) return;
758
- let repository;
759
- try {
760
- repository = await this.#resolveRepository({
761
- id: "github-subscription-hint",
762
- owner: evidence.owner,
763
- repo: evidence.repo,
764
- number: evidence.number
765
- });
766
- } catch {
767
- return;
768
- }
769
- await threadStore.saveThread({
770
- thread: {
771
- ...loadedThread,
772
- id: threadContext.threadId,
773
- resourceId: threadContext.resourceId,
774
- createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
775
- updatedAt: /* @__PURE__ */ new Date(),
776
- metadata: setGithubMetadata(loadedThread.metadata, { ...githubMetadata, subscriptionHintShown: true })
777
- }
778
- });
779
- await args.sendSignal?.({
780
- type: "reactive",
781
- tagName: "system-reminder",
782
- contents: `Looks like you're working with ${repository.owner}/${repository.repo}#${evidence.number}. Use /github subscribe ${evidence.number} or the github_subscribe_pr tool to follow updates.`,
783
- attributes: { type: "github-subscription-hint" },
784
- metadata: {
785
- github: {
786
- action: "subscriptionHint",
787
- owner: repository.owner,
788
- repo: repository.repo,
789
- number: evidence.number
790
- }
791
- }
792
- });
793
- }
794
- async #resolveThreadStore() {
795
- if (this.#options.threadStore) return this.#options.threadStore;
796
- const storage = this.#ghMastra?.getStorage?.();
797
- const memoryStore = storage?.getStore ? await storage.getStore("memory") : void 0;
798
- return memoryStore;
799
- }
800
- #getThreadContext(args) {
801
- const memoryContext = args.requestContext?.get("MastraMemory");
802
- return { threadId: memoryContext?.thread?.id, resourceId: memoryContext?.resourceId };
803
- }
804
- #createTools(args) {
805
- const threadContext = this.#getThreadContext(args);
806
- const getExecutionThreadContext = (context) => ({
807
- threadId: context?.agent?.threadId ?? threadContext.threadId,
808
- resourceId: context?.agent?.resourceId ?? threadContext.resourceId
809
- });
810
- return {
811
- ...args.tools,
812
- github_subscribe_pr: createGithubTool({
813
- id: "github_subscribe_pr",
814
- description: "Subscribe this thread to a GitHub pull request. Syncs only the requested PR with gitcrawl and stores the subscription on the thread.",
815
- inputSchema: z.object({
816
- number: z.number().int().positive(),
817
- owner: z.string().optional(),
818
- repo: z.string().optional()
819
- }),
820
- execute: async (input, context) => {
821
- const executionThreadContext = getExecutionThreadContext(context);
822
- const result = await this.#subscribe({
823
- id: `github-tool-subscribe-${randomUUID()}`,
824
- owner: input.owner,
825
- repo: input.repo,
826
- number: input.number,
827
- threadId: executionThreadContext.threadId,
828
- resourceId: executionThreadContext.resourceId
829
- });
830
- return {
831
- subscribed: true,
832
- owner: result.owner,
833
- repo: result.repo,
834
- number: result.number,
835
- syncStatus: result.syncResult?.ok === false ? "error" : result.syncResult ? "success" : void 0,
836
- message: result.syncResult?.ok === false ? `Subscribed to ${result.owner}/${result.repo}#${result.number}, but gitcrawl sync failed: ${result.syncResult.error}` : `Subscribed to ${result.owner}/${result.repo}#${result.number}.`
837
- };
838
- }
839
- }),
840
- github_unsubscribe_pr: createGithubTool({
841
- id: "github_unsubscribe_pr",
842
- description: "Unsubscribe this thread from a GitHub pull request.",
843
- inputSchema: z.object({
844
- number: z.number().int().positive(),
845
- owner: z.string().optional(),
846
- repo: z.string().optional()
847
- }),
848
- execute: async (input, context) => {
849
- const executionThreadContext = getExecutionThreadContext(context);
850
- const result = await this.#unsubscribe({
851
- id: `github-tool-unsubscribe-${randomUUID()}`,
852
- owner: input.owner,
853
- repo: input.repo,
854
- number: input.number,
855
- threadId: executionThreadContext.threadId,
856
- resourceId: executionThreadContext.resourceId
857
- });
858
- return {
859
- unsubscribed: result.removed ?? false,
860
- owner: result.owner,
861
- repo: result.repo,
862
- number: result.number,
863
- remainingSubscriptions: result.remainingSubscriptions,
864
- message: result.removed ? `Unsubscribed from ${result.owner}/${result.repo}#${result.number}.` : `No GitHub subscription found for ${result.owner}/${result.repo}#${result.number}.`
865
- };
866
- }
867
- })
868
- };
869
- }
870
- async #resolveRepository(input) {
871
- const resolvedRepository = input.owner && input.repo ? { owner: input.owner, repo: input.repo } : this.#options.owner && this.#options.repo ? { owner: this.#options.owner, repo: this.#options.repo } : await this.#repositoryResolver.resolveRepository({
872
- cwd: this.#options.cwd,
873
- abortSignal: input.abortSignal
874
- });
875
- if (!resolvedRepository?.owner || !resolvedRepository.repo) {
876
- throw new Error(
877
- "GitHub PR subscription requires owner and repo. Run inside a GitHub repo or pass owner and repo."
878
- );
879
- }
880
- return resolvedRepository;
881
- }
882
- async #loadThread(input) {
883
- const threadStore = await this.#resolveThreadStore();
884
- if (!threadStore) throw new Error("GitHub PR subscription requires memory-backed thread storage.");
885
- if (!input.threadId || !input.resourceId)
886
- throw new Error("GitHub PR subscription requires threadId and resourceId.");
887
- const loadedThread = await threadStore.getThreadById({ threadId: input.threadId, resourceId: input.resourceId }) ?? void 0;
888
- if (!loadedThread) throw new Error(`Could not load thread ${input.threadId}.`);
889
- return { threadStore, loadedThread };
890
- }
891
- #pollingKey(input) {
892
- return `${input.resourceId}:${input.threadId}`;
893
- }
894
- #getNotificationAgent(_input) {
895
- if (this.#agent) return this.#agent;
896
- const agentId = _input?.agentId ?? this.#options.agentId;
897
- return agentId ? this.#ghMastra?.getAgentById?.(agentId) : void 0;
898
- }
899
- async #getThreadSubscriptions(input) {
900
- const { loadedThread } = await this.#loadThread(input);
901
- return getGithubMetadata(loadedThread.metadata).subscriptions;
902
- }
903
- #notifySubscriptionsChanged(input) {
904
- this.#subscriptionsChangedHandler?.(input);
905
- }
906
- #notifyPollingChanged(input) {
907
- this.#pollingChangedHandler?.(input);
908
- }
909
- async #pollThread(input, options = {}) {
910
- const key = this.#pollingKey(input);
911
- const state = this.#polling.get(key);
912
- if (state?.running) {
913
- return 0;
914
- }
915
- if (state) state.running = true;
916
- this.#notifyPollingChanged({ threadId: input.threadId, resourceId: input.resourceId, running: true });
917
- try {
918
- const { threadStore, loadedThread } = await this.#loadThread(input);
919
- const githubMetadata = getGithubMetadata(loadedThread.metadata);
920
- if (githubMetadata.subscriptions.length === 0) {
921
- this.stopPollingForThread(input);
922
- return 0;
923
- }
924
- const now = (/* @__PURE__ */ new Date()).toISOString();
925
- const subscriptions = [];
926
- for (const subscription of githubMetadata.subscriptions) {
927
- const syncInput = {
928
- owner: subscription.owner,
929
- repo: subscription.repo,
930
- number: subscription.number,
931
- cwd: this.#options.cwd,
932
- includeComments: options.includeComments
933
- };
934
- const syncResult = await this.#syncClient.syncPullRequest(syncInput);
935
- let snapshot = syncResult.ok ? await this.#syncClient.getPullRequestSnapshot?.(syncInput) : void 0;
936
- if (snapshot)
937
- snapshot = await this.#filterUnauthorizedLatestComment(subscription.owner, subscription.repo, snapshot);
938
- const nextSubscription = {
939
- ...subscription,
940
- updatedAt: now,
941
- lastSyncAt: now,
942
- lastSyncStatus: syncResult.ok ? "success" : "error"
943
- };
944
- if (syncResult.error) nextSubscription.lastSyncError = syncResult.error;
945
- else delete nextSubscription.lastSyncError;
946
- const previousGithubUpdatedAt = subscription.lastObservedGithubUpdatedAt;
947
- const previousContentHash = subscription.lastObservedContentHash;
948
- const previousThreadContentHash = subscription.lastObservedThreadContentHash;
949
- const previousHeadSha = subscription.lastObservedHeadSha;
950
- const latestCommentChanged = !!previousGithubUpdatedAt && !!snapshot?.latestCommentUpdatedAt && Date.parse(snapshot.latestCommentUpdatedAt) > Date.parse(previousGithubUpdatedAt);
951
- if (snapshot) applySnapshotCursor(nextSubscription, snapshot);
952
- const isFirstObservation = syncResult.ok && snapshot && !previousGithubUpdatedAt && !previousContentHash;
953
- const legacyAggregateChanged = previousContentHash && snapshot?.contentHash && previousContentHash !== snapshot.contentHash && !previousThreadContentHash && !previousHeadSha;
954
- const changed = isFirstObservation || syncResult.ok && snapshot && (legacyAggregateChanged || latestCommentChanged || previousThreadContentHash && snapshot.threadContentHash && previousThreadContentHash !== snapshot.threadContentHash || previousHeadSha && snapshot.headSha && previousHeadSha !== snapshot.headSha || subscription.lastObservedState && snapshot.state && subscription.lastObservedState !== snapshot.state || subscription.lastObservedMergeableState && snapshot.mergeableState && subscription.lastObservedMergeableState !== snapshot.mergeableState || subscription.lastObservedCiState && snapshot.ciState && subscription.lastObservedCiState !== snapshot.ciState || subscription.lastObservedReviewStateHash && snapshot.reviewStateHash && subscription.lastObservedReviewStateHash !== snapshot.reviewStateHash);
955
- let shouldKeepSubscription = true;
956
- if (changed && snapshot) {
957
- const notifications = await this.#sendActivityNotifications({
958
- polling: input,
959
- subscription,
960
- snapshot,
961
- previousGithubUpdatedAt,
962
- previousContentHash,
963
- latestCommentChanged
964
- });
965
- const primaryNotification = notifications[0];
966
- if (primaryNotification) {
967
- nextSubscription.lastNotificationAt = now;
968
- nextSubscription.lastNotificationKind = primaryNotification.kind;
969
- nextSubscription.lastNotificationPriority = primaryNotification.priority;
970
- nextSubscription.lastNotificationSummary = primaryNotification.summary;
971
- shouldKeepSubscription = notifications.every((notification) => notification.kind !== "pull-request-merged");
972
- }
973
- }
974
- if (shouldKeepSubscription) subscriptions.push(nextSubscription);
975
- }
976
- await threadStore.saveThread({
977
- thread: {
978
- ...loadedThread,
979
- id: input.threadId,
980
- resourceId: input.resourceId,
981
- createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
982
- updatedAt: /* @__PURE__ */ new Date(),
983
- metadata: setGithubMetadata(loadedThread.metadata, { subscriptions })
984
- }
985
- });
986
- this.#notifySubscriptionsChanged({ threadId: input.threadId, resourceId: input.resourceId, subscriptions });
987
- if (subscriptions.length === 0) this.stopPollingForThread(input);
988
- return subscriptions.length;
989
- } catch (error) {
990
- throw error;
991
- } finally {
992
- const latestState = this.#polling.get(key);
993
- if (latestState) latestState.running = false;
994
- this.#notifyPollingChanged({ threadId: input.threadId, resourceId: input.resourceId, running: false });
995
- }
996
- }
997
- #createGithubNotificationInput(input) {
998
- const failingChecks = getFailingChecks(input.snapshot);
999
- const pendingChecks = getPendingChecks(input.snapshot);
1000
- const latestCommentExcerpt = input.snapshot.latestCommentBody ? getCommentExcerpt(input.snapshot.latestCommentBody) : void 0;
1001
- const latestCommentDedupeSuffix = input.notification.kind === "pull-request-activity" && input.snapshot.latestCommentUrl ? `comment:${input.snapshot.latestCommentUrl}:${input.snapshot.latestCommentUpdatedAt ?? ""}` : input.dedupeSuffix;
1002
- const notificationInput = {
1003
- source: "github",
1004
- kind: input.notification.kind,
1005
- priority: input.notification.priority,
1006
- summary: input.notification.summary,
1007
- dedupeKey: `github:${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}:${latestCommentDedupeSuffix}`,
1008
- coalesceKey: `github:${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}:${input.notification.kind}`,
1009
- attributes: {
1010
- owner: input.subscription.owner,
1011
- repo: input.subscription.repo,
1012
- number: input.subscription.number,
1013
- ...input.snapshot.title ? { title: input.snapshot.title } : {},
1014
- ...input.snapshot.state ? { state: input.snapshot.state } : {},
1015
- ...input.snapshot.htmlUrl ? { url: input.snapshot.htmlUrl } : {},
1016
- ...input.snapshot.githubUpdatedAt ? { githubUpdatedAt: input.snapshot.githubUpdatedAt } : {},
1017
- ...input.previousGithubUpdatedAt ? { previousGithubUpdatedAt: input.previousGithubUpdatedAt } : {},
1018
- ...input.snapshot.mergeableState ? { mergeableState: input.snapshot.mergeableState } : {},
1019
- ...input.snapshot.ciState ? { ciState: input.snapshot.ciState } : {},
1020
- ...input.snapshot.unresolvedReviewThreads !== void 0 ? { unresolvedReviewThreads: input.snapshot.unresolvedReviewThreads } : {},
1021
- ...input.snapshot.latestCommentAuthor ? { latestCommentAuthor: input.snapshot.latestCommentAuthor } : {},
1022
- ...latestCommentExcerpt ? { latestCommentExcerpt } : {},
1023
- ...input.snapshot.latestCommentUrl ? { latestCommentUrl: input.snapshot.latestCommentUrl } : {},
1024
- ...input.snapshot.latestCommentUpdatedAt ? { latestCommentUpdatedAt: input.snapshot.latestCommentUpdatedAt } : {},
1025
- ...failingChecks.length > 0 ? { failingChecks: failingChecks.map((check) => check.name).join(", ") } : {},
1026
- ...pendingChecks.length > 0 ? { pendingChecks: pendingChecks.map((check) => check.name).join(", ") } : {}
1027
- },
1028
- metadata: {
1029
- github: {
1030
- owner: input.subscription.owner,
1031
- repo: input.subscription.repo,
1032
- number: input.subscription.number,
1033
- title: input.snapshot.title,
1034
- state: input.snapshot.state,
1035
- htmlUrl: input.snapshot.htmlUrl,
1036
- githubUpdatedAt: input.snapshot.githubUpdatedAt,
1037
- previousGithubUpdatedAt: input.previousGithubUpdatedAt,
1038
- contentHash: input.snapshot.contentHash,
1039
- previousContentHash: input.previousContentHash,
1040
- threadContentHash: input.snapshot.threadContentHash,
1041
- headSha: input.snapshot.headSha,
1042
- headRef: input.snapshot.headRef,
1043
- mergeableState: input.snapshot.mergeableState,
1044
- ciState: input.snapshot.ciState,
1045
- closedAt: input.snapshot.closedAt,
1046
- mergedAt: input.snapshot.mergedAt,
1047
- unresolvedReviewThreads: input.snapshot.unresolvedReviewThreads,
1048
- reviewStateHash: input.snapshot.reviewStateHash,
1049
- latestReviewThreadAt: input.snapshot.latestReviewThreadAt,
1050
- latestCommentAuthor: input.snapshot.latestCommentAuthor,
1051
- latestCommentAuthorType: input.snapshot.latestCommentAuthorType,
1052
- latestCommentIsBot: input.snapshot.latestCommentIsBot,
1053
- // Intentionally omit the full latestCommentBody here: persisting it verbatim bloats
1054
- // notification payloads (a single CodeRabbit comment can exceed 100KB) and can overflow
1055
- // agent context windows when listed. The 240-char latestCommentExcerpt is stored instead.
1056
- latestCommentExcerpt,
1057
- latestCommentUrl: input.snapshot.latestCommentUrl,
1058
- latestCommentUpdatedAt: input.snapshot.latestCommentUpdatedAt,
1059
- failingChecks,
1060
- pendingChecks
1061
- }
1062
- }
1063
- };
1064
- return notificationInput;
1065
- }
1066
- async #sendGithubNotification(input) {
1067
- const notificationInput = this.#createGithubNotificationInput(input);
1068
- const streamOptions = await this.#agentOptions.getNotificationStreamOptions?.(input.target);
1069
- await input.agent?.sendNotificationSignal?.(
1070
- notificationInput,
1071
- streamOptions ? { ...input.target, ifIdle: { streamOptions } } : input.target
1072
- );
1073
- }
1074
- async #sendBaselineNotification(input) {
1075
- const agent = this.#getNotificationAgent({});
1076
- if (!agent?.sendNotificationSignal) return;
1077
- await this.#sendGithubNotification({
1078
- agent,
1079
- subscription: input.subscription,
1080
- snapshot: input.snapshot,
1081
- notification: classifyGithubBaselineNotification({ subscription: input.subscription, snapshot: input.snapshot }),
1082
- target: { resourceId: input.resourceId, threadId: input.threadId },
1083
- dedupeSuffix: `baseline:${input.subscription.lastSubscribeSignalId}`
1084
- });
1085
- }
1086
- async #isAuthorizedAuthor(owner, repo, user, metadata = {}) {
1087
- if (!user) return false;
1088
- const normalizedUser = user.toLowerCase();
1089
- const isBot = metadata.isBot === true || metadata.authorType?.toLowerCase() === "bot" || normalizedUser.endsWith("[bot]");
1090
- if (isBot) {
1091
- const ignoredBots = this.#options.ignoredBots ?? [];
1092
- if (ignoredBots.some((bot) => bot.toLowerCase() === normalizedUser)) return false;
1093
- const authorizedBots = this.#options.authorizedBots ?? DEFAULT_AUTHORIZED_BOTS;
1094
- return authorizedBots.some((bot) => bot.toLowerCase() === normalizedUser);
1095
- }
1096
- const permission = await this.#loadAuthorPermission(owner, repo, user);
1097
- const authorizedPermissions = this.#options.authorizedPermissions ?? DEFAULT_AUTHORIZED_PERMISSIONS;
1098
- return !!permission && authorizedPermissions.includes(permission);
1099
- }
1100
- async #filterUnauthorizedLatestComment(owner, repo, snapshot) {
1101
- const comments = snapshot.latestComments?.length ? snapshot.latestComments : [
1102
- {
1103
- author: snapshot.latestCommentAuthor,
1104
- authorType: snapshot.latestCommentAuthorType,
1105
- isBot: snapshot.latestCommentIsBot,
1106
- body: snapshot.latestCommentBody,
1107
- url: snapshot.latestCommentUrl,
1108
- updatedAt: snapshot.latestCommentUpdatedAt
1109
- }
1110
- ];
1111
- if (!comments.some((comment) => comment.author)) return snapshot;
1112
- if (!comments.some((comment) => comment.body || comment.url || comment.updatedAt)) return snapshot;
1113
- for (const comment of comments) {
1114
- if (!await this.#isAuthorizedAuthor(owner, repo, comment.author, {
1115
- authorType: comment.authorType,
1116
- isBot: comment.isBot
1117
- })) {
1118
- continue;
1119
- }
1120
- return {
1121
- ...snapshot,
1122
- latestCommentAuthor: comment.author,
1123
- latestCommentAuthorType: comment.authorType,
1124
- latestCommentIsBot: comment.isBot,
1125
- latestCommentBody: comment.body,
1126
- latestCommentUrl: comment.url,
1127
- latestCommentUpdatedAt: comment.updatedAt
1128
- };
1129
- }
1130
- return {
1131
- ...snapshot,
1132
- latestCommentAuthor: void 0,
1133
- latestCommentAuthorType: void 0,
1134
- latestCommentIsBot: void 0,
1135
- latestCommentBody: void 0,
1136
- latestCommentUrl: void 0,
1137
- latestCommentUpdatedAt: void 0
1138
- };
1139
- }
1140
- async #loadAuthorPermission(owner, repo, user) {
1141
- const cacheKey = `${owner}/${repo}:${user.toLowerCase()}`;
1142
- const cached = this.#permissionCache.get(cacheKey);
1143
- if (cached && cached.expiresAt > Date.now()) return cached.permission;
1144
- if (cached) this.#permissionCache.delete(cacheKey);
1145
- try {
1146
- let permission;
1147
- if (this.#options.permissionResolver) {
1148
- permission = await this.#options.permissionResolver.getPermission(owner, repo, user);
1149
- } else {
1150
- const { stdout } = await execFileAsync("gh", [
1151
- "api",
1152
- `repos/${owner}/${repo}/collaborators/${user}/permission`,
1153
- "--jq",
1154
- ".permission"
1155
- ]);
1156
- const raw = stdout.trim();
1157
- permission = ["admin", "maintain", "write", "triage", "read", "none"].includes(
1158
- raw
1159
- ) ? raw : void 0;
1160
- }
1161
- if (permission) {
1162
- this.#permissionCache.set(cacheKey, { permission, expiresAt: Date.now() + PERMISSION_CACHE_TTL_MS });
1163
- }
1164
- return permission;
1165
- } catch {
1166
- this.#permissionCache.delete(cacheKey);
1167
- return void 0;
1168
- }
1169
- }
1170
- async #sendActivityNotifications(input) {
1171
- const agent = this.#getNotificationAgent(input.polling);
1172
- if (!agent?.sendNotificationSignal) return [];
1173
- const notifications = [
1174
- classifyGithubActivityNotification({
1175
- subscription: input.subscription,
1176
- snapshot: input.snapshot
1177
- })
1178
- ];
1179
- if (input.latestCommentChanged && notifications[0]?.kind !== "pull-request-activity") {
1180
- notifications.push(
1181
- classifyGithubCommentActivityNotification({
1182
- subscription: input.subscription,
1183
- snapshot: input.snapshot
1184
- })
1185
- );
1186
- }
1187
- const sent = [];
1188
- const notificationInputs = [];
1189
- for (const notification of notifications.sort(compareGithubActivityNotifications)) {
1190
- if (!notification) continue;
1191
- if (AUTHOR_GATED_NOTIFICATION_KINDS.has(notification.kind)) {
1192
- const authorized = await this.#isAuthorizedAuthor(
1193
- input.subscription.owner,
1194
- input.subscription.repo,
1195
- input.snapshot.latestCommentAuthor,
1196
- {
1197
- authorType: input.snapshot.latestCommentAuthorType,
1198
- isBot: input.snapshot.latestCommentIsBot
1199
- }
1200
- );
1201
- if (!authorized) continue;
1202
- }
1203
- notificationInputs.push(
1204
- this.#createGithubNotificationInput({
1205
- subscription: input.subscription,
1206
- snapshot: input.snapshot,
1207
- notification,
1208
- dedupeSuffix: input.snapshot.contentHash ?? input.snapshot.githubUpdatedAt ?? String(Date.now()),
1209
- previousGithubUpdatedAt: input.previousGithubUpdatedAt,
1210
- previousContentHash: input.previousContentHash
1211
- })
1212
- );
1213
- sent.push(notification);
1214
- }
1215
- if (notificationInputs.length > 0) {
1216
- const target = { resourceId: input.polling.resourceId, threadId: input.polling.threadId };
1217
- const streamOptions = await this.#agentOptions.getNotificationStreamOptions?.(target);
1218
- await agent.sendNotificationSignal(
1219
- notificationInputs,
1220
- streamOptions ? { ...target, ifIdle: { streamOptions } } : target
1221
- );
1222
- }
1223
- return sent;
1224
- }
1225
- async #subscribe(input) {
1226
- const { owner, repo } = await this.#resolveRepository(input);
1227
- const { threadStore, loadedThread } = await this.#loadThread(input);
1228
- const githubMetadata = getGithubMetadata(loadedThread.metadata);
1229
- const existingIndex = githubMetadata.subscriptions.findIndex(
1230
- (subscription2) => subscription2.owner === owner && subscription2.repo === repo && subscription2.number === input.number
1231
- );
1232
- const existing = existingIndex >= 0 ? githubMetadata.subscriptions[existingIndex] : void 0;
1233
- if (existing?.lastSubscribeSignalId === input.id) {
1234
- return { owner, repo, number: input.number, subscription: existing, alreadyProcessed: true };
1235
- }
1236
- const now = (/* @__PURE__ */ new Date()).toISOString();
1237
- const subscription = {
1238
- owner,
1239
- repo,
1240
- number: input.number,
1241
- subscribedAt: existing?.subscribedAt ?? now,
1242
- updatedAt: now,
1243
- lastSubscribeSignalId: input.id,
1244
- ...existing?.lastSyncAt ? { lastSyncAt: existing.lastSyncAt } : {},
1245
- ...existing?.lastSyncStatus ? { lastSyncStatus: existing.lastSyncStatus } : {},
1246
- ...existing?.lastSyncError ? { lastSyncError: existing.lastSyncError } : {},
1247
- ...existing?.lastObservedGithubUpdatedAt ? { lastObservedGithubUpdatedAt: existing.lastObservedGithubUpdatedAt } : {},
1248
- ...existing?.lastObservedContentHash ? { lastObservedContentHash: existing.lastObservedContentHash } : {},
1249
- ...existing?.lastObservedThreadContentHash ? { lastObservedThreadContentHash: existing.lastObservedThreadContentHash } : {},
1250
- ...existing?.lastObservedHeadSha ? { lastObservedHeadSha: existing.lastObservedHeadSha } : {},
1251
- ...existing?.lastObservedState ? { lastObservedState: existing.lastObservedState } : {},
1252
- ...existing?.lastObservedMergeableState ? { lastObservedMergeableState: existing.lastObservedMergeableState } : {},
1253
- ...existing?.lastObservedCiState ? { lastObservedCiState: existing.lastObservedCiState } : {},
1254
- ...existing?.lastObservedReviewStateHash ? { lastObservedReviewStateHash: existing.lastObservedReviewStateHash } : {}
1255
- };
1256
- let syncResult;
1257
- let baselineSnapshot;
1258
- if (this.#options.syncOnSubscribe !== false) {
1259
- const syncInput = {
1260
- owner,
1261
- repo,
1262
- number: input.number,
1263
- cwd: this.#options.cwd,
1264
- abortSignal: input.abortSignal
1265
- };
1266
- syncResult = await this.#syncClient.syncPullRequest(syncInput);
1267
- subscription.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
1268
- subscription.lastSyncStatus = syncResult.ok ? "success" : "error";
1269
- if (syncResult.error) subscription.lastSyncError = syncResult.error;
1270
- else delete subscription.lastSyncError;
1271
- const snapshot = syncResult.ok ? await this.#syncClient.getPullRequestSnapshot?.(syncInput) : void 0;
1272
- baselineSnapshot = snapshot;
1273
- if (snapshot) applySnapshotCursor(subscription, snapshot);
1274
- } else {
1275
- subscription.lastSyncStatus = "skipped";
1276
- }
1277
- const subscriptions = [subscription];
1278
- await threadStore.saveThread({
1279
- thread: {
1280
- ...loadedThread,
1281
- id: input.threadId,
1282
- resourceId: input.resourceId,
1283
- createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
1284
- updatedAt: /* @__PURE__ */ new Date(),
1285
- metadata: setGithubMetadata(loadedThread.metadata, { subscriptions })
1286
- }
1287
- });
1288
- this.#notifySubscriptionsChanged({ threadId: input.threadId, resourceId: input.resourceId, subscriptions });
1289
- if (baselineSnapshot) {
1290
- await this.#sendBaselineNotification({
1291
- threadId: input.threadId,
1292
- resourceId: input.resourceId,
1293
- subscription,
1294
- snapshot: baselineSnapshot
1295
- });
1296
- }
1297
- await this.startPollingForThread({ threadId: input.threadId, resourceId: input.resourceId });
1298
- return { owner, repo, number: input.number, subscription, syncResult };
1299
- }
1300
- async #unsubscribe(input) {
1301
- const { owner, repo } = await this.#resolveRepository(input);
1302
- const { threadStore, loadedThread } = await this.#loadThread(input);
1303
- const githubMetadata = getGithubMetadata(loadedThread.metadata);
1304
- const subscriptions = githubMetadata.subscriptions.filter(
1305
- (subscription) => !(subscription.owner === owner && subscription.repo === repo && subscription.number === input.number)
1306
- );
1307
- const removed = subscriptions.length !== githubMetadata.subscriptions.length;
1308
- if (removed) {
1309
- await threadStore.saveThread({
1310
- thread: {
1311
- ...loadedThread,
1312
- id: input.threadId,
1313
- resourceId: input.resourceId,
1314
- createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
1315
- updatedAt: /* @__PURE__ */ new Date(),
1316
- metadata: setGithubMetadata(loadedThread.metadata, { subscriptions })
1317
- }
1318
- });
1319
- this.#notifySubscriptionsChanged({ threadId: input.threadId, resourceId: input.resourceId, subscriptions });
1320
- if (subscriptions.length === 0)
1321
- this.stopPollingForThread({ threadId: input.threadId, resourceId: input.resourceId });
1322
- }
1323
- return { owner, repo, number: input.number, removed, remainingSubscriptions: subscriptions.length };
1324
- }
1325
- #findLatestGithubSignal(messages) {
1326
- const message = messages.at(-1);
1327
- if (!message) return void 0;
1328
- const signal = getSignalMetadata(message);
1329
- if (!signal || signal.tagName !== GITHUB_SUBSCRIBE_PR_TAG && signal.tagName !== GITHUB_UNSUBSCRIBE_PR_TAG) {
1330
- return void 0;
1331
- }
1332
- const attributes = isPlainObject(signal.attributes) ? signal.attributes : {};
1333
- const metadata = isPlainObject(signal.metadata) ? signal.metadata : {};
1334
- const github = isPlainObject(metadata.github) ? metadata.github : {};
1335
- const number = readNumber(attributes.number) ?? readNumber(github.number);
1336
- if (!number) return void 0;
1337
- return {
1338
- tagName: String(signal.tagName),
1339
- id: readString(signal.id) ?? message.id,
1340
- owner: readString(attributes.owner) ?? readString(github.owner),
1341
- repo: readString(attributes.repo) ?? readString(github.repo),
1342
- number
1343
- };
1344
- }
1345
- async #sendStatus(args, signal, status) {
1346
- await args.sendSignal?.({
1347
- type: "reactive",
1348
- tagName: GITHUB_SYNC_STATUS_TAG,
1349
- contents: status.message,
1350
- attributes: {
1351
- status: status.status,
1352
- owner: signal.owner,
1353
- repo: signal.repo,
1354
- number: signal.number
1355
- },
1356
- metadata: {
1357
- github: {
1358
- action: status.action,
1359
- status: status.status,
1360
- owner: signal.owner,
1361
- repo: signal.repo,
1362
- number: signal.number
1363
- }
1364
- }
1365
- });
1366
- }
660
+ id = "github-signals";
661
+ name = "GitHub Signals";
662
+ #ghMastra;
663
+ static signals = {
664
+ subscribeToPR(input) {
665
+ const normalized = typeof input === "number" ? { number: input } : input;
666
+ return {
667
+ type: "reactive",
668
+ tagName: GITHUB_SUBSCRIBE_PR_TAG,
669
+ contents: `Subscribe to GitHub PR #${normalized.number}`,
670
+ attributes: {
671
+ ...normalized.owner ? { owner: normalized.owner } : {},
672
+ ...normalized.repo ? { repo: normalized.repo } : {},
673
+ number: normalized.number
674
+ },
675
+ metadata: { github: {
676
+ action: "subscribeToPR",
677
+ ...normalized
678
+ } }
679
+ };
680
+ },
681
+ unsubscribeFromPR(input) {
682
+ const normalized = typeof input === "number" ? { number: input } : input;
683
+ return {
684
+ type: "reactive",
685
+ tagName: GITHUB_UNSUBSCRIBE_PR_TAG,
686
+ contents: `Unsubscribe from GitHub PR #${normalized.number}`,
687
+ attributes: {
688
+ ...normalized.owner ? { owner: normalized.owner } : {},
689
+ ...normalized.repo ? { repo: normalized.repo } : {},
690
+ number: normalized.number
691
+ },
692
+ metadata: { github: {
693
+ action: "unsubscribeFromPR",
694
+ ...normalized
695
+ } }
696
+ };
697
+ }
698
+ };
699
+ #options;
700
+ #syncClient;
701
+ #repositoryResolver;
702
+ #polling = /* @__PURE__ */ new Map();
703
+ #permissionCache = /* @__PURE__ */ new Map();
704
+ #agent;
705
+ #agentOptions = {};
706
+ #subscriptionsChangedHandler;
707
+ #pollingChangedHandler;
708
+ constructor(options = {}) {
709
+ super();
710
+ this.#options = options;
711
+ this.#syncClient = options.syncClient ?? new GitcrawlSyncClient({ command: options.gitcrawlCommand });
712
+ this.#repositoryResolver = options.repositoryResolver ?? new GitRemoteRepositoryResolver();
713
+ if (options.getNotificationStreamOptions) this.#agentOptions = { getNotificationStreamOptions: options.getNotificationStreamOptions };
714
+ }
715
+ /**
716
+ * @deprecated Use `Agent({ signals: [githubSignals] })` instead.
717
+ * Kept for backward compatibility.
718
+ */
719
+ addAgent(agent, options = {}) {
720
+ this.#agent = agent;
721
+ this.#agentOptions = options;
722
+ }
723
+ /**
724
+ * Called by the Agent constructor when this provider is passed via `signals: [...]`.
725
+ * Sets the bidirectional link so the provider can send signals back to the agent.
726
+ */
727
+ connect(agent) {
728
+ super.connect(agent);
729
+ this.#agent = agent;
730
+ }
731
+ getInputProcessors() {
732
+ return [this];
733
+ }
734
+ getOutputProcessors() {
735
+ return [this];
736
+ }
737
+ onSubscriptionsChanged(handler) {
738
+ this.#subscriptionsChangedHandler = handler;
739
+ }
740
+ onPollingChanged(handler) {
741
+ this.#pollingChangedHandler = handler;
742
+ }
743
+ __registerMastra(mastra) {
744
+ super.__registerMastra(mastra);
745
+ this.#ghMastra = mastra;
746
+ }
747
+ async syncThreadNow(input) {
748
+ return this.#pollThread(input, { includeComments: true });
749
+ }
750
+ async subscribeThreadToPR(input) {
751
+ const pr = typeof input.pr === "number" ? { number: input.pr } : input.pr;
752
+ return this.#subscribe({
753
+ id: `github-command-subscribe-${randomUUID()}`,
754
+ ...pr,
755
+ threadId: input.threadId,
756
+ resourceId: input.resourceId
757
+ });
758
+ }
759
+ async unsubscribeThreadFromPR(input) {
760
+ const pr = typeof input.pr === "number" ? { number: input.pr } : input.pr;
761
+ return this.#unsubscribe({
762
+ id: `github-command-unsubscribe-${randomUUID()}`,
763
+ ...pr,
764
+ threadId: input.threadId,
765
+ resourceId: input.resourceId
766
+ });
767
+ }
768
+ async startPollingForThread(input, options = {}) {
769
+ if ((await this.#getThreadSubscriptions(input)).length === 0) {
770
+ this.stopPollingForThread(input);
771
+ return false;
772
+ }
773
+ const key = this.#pollingKey(input);
774
+ for (const [pollingKey, state] of this.#polling.entries()) {
775
+ if (pollingKey === key) continue;
776
+ clearInterval(state.timer);
777
+ this.#polling.delete(pollingKey);
778
+ }
779
+ if (this.#polling.has(key)) return true;
780
+ const runPoll = (pollOptions = {}) => {
781
+ this.#pollThread(input, pollOptions).catch((error) => {
782
+ console.warn("GitHub PR polling failed:", error);
783
+ });
784
+ };
785
+ const timer = setInterval(() => {
786
+ runPoll({ includeComments: true });
787
+ }, this.#options.pollIntervalMs ?? 3e5);
788
+ if (options.pollImmediately) runPoll({ includeComments: true });
789
+ timer.unref?.();
790
+ this.#polling.set(key, {
791
+ ...input,
792
+ timer,
793
+ running: false
794
+ });
795
+ return true;
796
+ }
797
+ stopPollingForThread(input) {
798
+ const key = this.#pollingKey(input);
799
+ const state = this.#polling.get(key);
800
+ if (!state) return;
801
+ clearInterval(state.timer);
802
+ this.#polling.delete(key);
803
+ }
804
+ isPollingThread(input) {
805
+ return this.#polling.has(this.#pollingKey(input));
806
+ }
807
+ isPollingThreadRunning(input) {
808
+ return this.#polling.get(this.#pollingKey(input))?.running ?? false;
809
+ }
810
+ getPollIntervalMs() {
811
+ return this.#options.pollIntervalMs ?? 3e5;
812
+ }
813
+ stopAllPolling() {
814
+ for (const state of this.#polling.values()) clearInterval(state.timer);
815
+ this.#polling.clear();
816
+ }
817
+ async pollThreadNow(input) {
818
+ return this.#pollThread(input, { includeComments: true });
819
+ }
820
+ async processInputStep(args) {
821
+ const tools = this.#createTools(args);
822
+ if (args.stepNumber !== 0) return { tools };
823
+ const signal = this.#findLatestGithubSignal(args.messages);
824
+ if (!signal) return { tools };
825
+ const threadContext = this.#getThreadContext(args);
826
+ if (signal.tagName === "github-unsubscribe-pr") {
827
+ const result = await this.#unsubscribe({
828
+ ...signal,
829
+ ...threadContext,
830
+ abortSignal: args.abortSignal
831
+ });
832
+ await this.#sendStatus(args, result, {
833
+ status: result.removed ? "unsubscribed" : "not_subscribed",
834
+ action: "unsubscribeFromPR",
835
+ message: result.removed ? `Unsubscribed from ${result.owner}/${result.repo}#${result.number}.` : `No GitHub subscription found for ${result.owner}/${result.repo}#${result.number}.`
836
+ });
837
+ return { tools };
838
+ }
839
+ const result = await this.#subscribe({
840
+ ...signal,
841
+ ...threadContext,
842
+ abortSignal: args.abortSignal
843
+ });
844
+ if (result.alreadyProcessed) return { tools };
845
+ await this.#sendStatus(args, result, {
846
+ status: result.syncResult?.ok === false ? "sync_error" : "subscribed",
847
+ action: "subscribeToPR",
848
+ message: result.syncResult?.ok === false ? `Subscribed to ${result.owner}/${result.repo}#${result.number}, but gitcrawl sync failed: ${result.syncResult.error}` : `Subscribed to ${result.owner}/${result.repo}#${result.number}.`
849
+ });
850
+ return { tools };
851
+ }
852
+ async processOutputStep(args) {
853
+ const evidence = detectPrWorkEvidence({
854
+ text: args.text,
855
+ toolCalls: args.toolCalls
856
+ });
857
+ if (!evidence) return;
858
+ const threadContext = this.#getThreadContext(args);
859
+ if (!threadContext.threadId || !threadContext.resourceId) return;
860
+ const { threadStore, loadedThread } = await this.#loadThread(threadContext);
861
+ const githubMetadata = getGithubMetadata(loadedThread.metadata);
862
+ if (githubMetadata.subscriptionHintShown || githubMetadata.subscriptions.length > 0) return;
863
+ let repository;
864
+ try {
865
+ repository = await this.#resolveRepository({
866
+ id: "github-subscription-hint",
867
+ owner: evidence.owner,
868
+ repo: evidence.repo,
869
+ number: evidence.number
870
+ });
871
+ } catch {
872
+ return;
873
+ }
874
+ await threadStore.saveThread({ thread: {
875
+ ...loadedThread,
876
+ id: threadContext.threadId,
877
+ resourceId: threadContext.resourceId,
878
+ createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
879
+ updatedAt: /* @__PURE__ */ new Date(),
880
+ metadata: setGithubMetadata(loadedThread.metadata, {
881
+ ...githubMetadata,
882
+ subscriptionHintShown: true
883
+ })
884
+ } });
885
+ await args.sendSignal?.({
886
+ type: "reactive",
887
+ tagName: "system-reminder",
888
+ contents: `Looks like you're working with ${repository.owner}/${repository.repo}#${evidence.number}. Use /github subscribe ${evidence.number} or the github_subscribe_pr tool to follow updates.`,
889
+ attributes: { type: "github-subscription-hint" },
890
+ metadata: { github: {
891
+ action: "subscriptionHint",
892
+ owner: repository.owner,
893
+ repo: repository.repo,
894
+ number: evidence.number
895
+ } }
896
+ });
897
+ }
898
+ async #resolveThreadStore() {
899
+ if (this.#options.threadStore) return this.#options.threadStore;
900
+ const storage = this.#ghMastra?.getStorage?.();
901
+ return storage?.getStore ? await storage.getStore("memory") : void 0;
902
+ }
903
+ #getThreadContext(args) {
904
+ const memoryContext = args.requestContext?.get("MastraMemory");
905
+ return {
906
+ threadId: memoryContext?.thread?.id,
907
+ resourceId: memoryContext?.resourceId
908
+ };
909
+ }
910
+ #createTools(args) {
911
+ const threadContext = this.#getThreadContext(args);
912
+ const getExecutionThreadContext = (context) => ({
913
+ threadId: context?.agent?.threadId ?? threadContext.threadId,
914
+ resourceId: context?.agent?.resourceId ?? threadContext.resourceId
915
+ });
916
+ return {
917
+ ...args.tools,
918
+ github_subscribe_pr: createGithubTool({
919
+ id: "github_subscribe_pr",
920
+ description: "Subscribe this thread to a GitHub pull request. Syncs only the requested PR with gitcrawl and stores the subscription on the thread.",
921
+ inputSchema: z.object({
922
+ number: z.number().int().positive(),
923
+ owner: z.string().optional(),
924
+ repo: z.string().optional()
925
+ }),
926
+ execute: async (input, context) => {
927
+ const executionThreadContext = getExecutionThreadContext(context);
928
+ const result = await this.#subscribe({
929
+ id: `github-tool-subscribe-${randomUUID()}`,
930
+ owner: input.owner,
931
+ repo: input.repo,
932
+ number: input.number,
933
+ threadId: executionThreadContext.threadId,
934
+ resourceId: executionThreadContext.resourceId
935
+ });
936
+ return {
937
+ subscribed: true,
938
+ owner: result.owner,
939
+ repo: result.repo,
940
+ number: result.number,
941
+ syncStatus: result.syncResult?.ok === false ? "error" : result.syncResult ? "success" : void 0,
942
+ message: result.syncResult?.ok === false ? `Subscribed to ${result.owner}/${result.repo}#${result.number}, but gitcrawl sync failed: ${result.syncResult.error}` : `Subscribed to ${result.owner}/${result.repo}#${result.number}.`
943
+ };
944
+ }
945
+ }),
946
+ github_unsubscribe_pr: createGithubTool({
947
+ id: "github_unsubscribe_pr",
948
+ description: "Unsubscribe this thread from a GitHub pull request.",
949
+ inputSchema: z.object({
950
+ number: z.number().int().positive(),
951
+ owner: z.string().optional(),
952
+ repo: z.string().optional()
953
+ }),
954
+ execute: async (input, context) => {
955
+ const executionThreadContext = getExecutionThreadContext(context);
956
+ const result = await this.#unsubscribe({
957
+ id: `github-tool-unsubscribe-${randomUUID()}`,
958
+ owner: input.owner,
959
+ repo: input.repo,
960
+ number: input.number,
961
+ threadId: executionThreadContext.threadId,
962
+ resourceId: executionThreadContext.resourceId
963
+ });
964
+ return {
965
+ unsubscribed: result.removed ?? false,
966
+ owner: result.owner,
967
+ repo: result.repo,
968
+ number: result.number,
969
+ remainingSubscriptions: result.remainingSubscriptions,
970
+ message: result.removed ? `Unsubscribed from ${result.owner}/${result.repo}#${result.number}.` : `No GitHub subscription found for ${result.owner}/${result.repo}#${result.number}.`
971
+ };
972
+ }
973
+ })
974
+ };
975
+ }
976
+ async #resolveRepository(input) {
977
+ const resolvedRepository = input.owner && input.repo ? {
978
+ owner: input.owner,
979
+ repo: input.repo
980
+ } : this.#options.owner && this.#options.repo ? {
981
+ owner: this.#options.owner,
982
+ repo: this.#options.repo
983
+ } : await this.#repositoryResolver.resolveRepository({
984
+ cwd: this.#options.cwd,
985
+ abortSignal: input.abortSignal
986
+ });
987
+ if (!resolvedRepository?.owner || !resolvedRepository.repo) throw new Error("GitHub PR subscription requires owner and repo. Run inside a GitHub repo or pass owner and repo.");
988
+ return resolvedRepository;
989
+ }
990
+ async #loadThread(input) {
991
+ const threadStore = await this.#resolveThreadStore();
992
+ if (!threadStore) throw new Error("GitHub PR subscription requires memory-backed thread storage.");
993
+ if (!input.threadId || !input.resourceId) throw new Error("GitHub PR subscription requires threadId and resourceId.");
994
+ const loadedThread = await threadStore.getThreadById({
995
+ threadId: input.threadId,
996
+ resourceId: input.resourceId
997
+ }) ?? void 0;
998
+ if (!loadedThread) throw new Error(`Could not load thread ${input.threadId}.`);
999
+ return {
1000
+ threadStore,
1001
+ loadedThread
1002
+ };
1003
+ }
1004
+ #pollingKey(input) {
1005
+ return `${input.resourceId}:${input.threadId}`;
1006
+ }
1007
+ #getNotificationAgent(_input) {
1008
+ if (this.#agent) return this.#agent;
1009
+ const agentId = _input?.agentId ?? this.#options.agentId;
1010
+ return agentId ? this.#ghMastra?.getAgentById?.(agentId) : void 0;
1011
+ }
1012
+ async #getThreadSubscriptions(input) {
1013
+ const { loadedThread } = await this.#loadThread(input);
1014
+ return getGithubMetadata(loadedThread.metadata).subscriptions;
1015
+ }
1016
+ #notifySubscriptionsChanged(input) {
1017
+ this.#subscriptionsChangedHandler?.(input);
1018
+ }
1019
+ #notifyPollingChanged(input) {
1020
+ this.#pollingChangedHandler?.(input);
1021
+ }
1022
+ async #pollThread(input, options = {}) {
1023
+ const key = this.#pollingKey(input);
1024
+ const state = this.#polling.get(key);
1025
+ if (state?.running) return 0;
1026
+ if (state) state.running = true;
1027
+ this.#notifyPollingChanged({
1028
+ threadId: input.threadId,
1029
+ resourceId: input.resourceId,
1030
+ running: true
1031
+ });
1032
+ try {
1033
+ const { threadStore, loadedThread } = await this.#loadThread(input);
1034
+ const githubMetadata = getGithubMetadata(loadedThread.metadata);
1035
+ if (githubMetadata.subscriptions.length === 0) {
1036
+ this.stopPollingForThread(input);
1037
+ return 0;
1038
+ }
1039
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1040
+ const subscriptions = [];
1041
+ for (const subscription of githubMetadata.subscriptions) {
1042
+ const syncInput = {
1043
+ owner: subscription.owner,
1044
+ repo: subscription.repo,
1045
+ number: subscription.number,
1046
+ cwd: this.#options.cwd,
1047
+ includeComments: options.includeComments
1048
+ };
1049
+ const syncResult = await this.#syncClient.syncPullRequest(syncInput);
1050
+ let snapshot;
1051
+ let snapshotError;
1052
+ if (syncResult.ok) try {
1053
+ snapshot = await this.#syncClient.getPullRequestSnapshot?.(syncInput);
1054
+ } catch (error) {
1055
+ snapshotError = error instanceof Error ? error.message : String(error);
1056
+ }
1057
+ if (snapshot) snapshot = await this.#filterUnauthorizedLatestComment(subscription.owner, subscription.repo, snapshot);
1058
+ const nextSubscription = {
1059
+ ...subscription,
1060
+ updatedAt: now,
1061
+ lastSyncAt: now,
1062
+ lastSyncStatus: syncResult.ok ? "success" : "error"
1063
+ };
1064
+ if (syncResult.error) nextSubscription.lastSyncError = syncResult.error;
1065
+ else delete nextSubscription.lastSyncError;
1066
+ if (snapshotError) nextSubscription.lastSnapshotError = snapshotError;
1067
+ else delete nextSubscription.lastSnapshotError;
1068
+ const previousGithubUpdatedAt = subscription.lastObservedGithubUpdatedAt;
1069
+ const previousContentHash = subscription.lastObservedContentHash;
1070
+ const previousThreadContentHash = subscription.lastObservedThreadContentHash;
1071
+ const previousHeadSha = subscription.lastObservedHeadSha;
1072
+ const latestCommentChanged = !!previousGithubUpdatedAt && !!snapshot?.latestCommentUpdatedAt && Date.parse(snapshot.latestCommentUpdatedAt) > Date.parse(previousGithubUpdatedAt);
1073
+ if (snapshot) applySnapshotCursor(nextSubscription, snapshot);
1074
+ const isFirstObservation = syncResult.ok && snapshot && !previousGithubUpdatedAt && !previousContentHash;
1075
+ const legacyAggregateChanged = previousContentHash && snapshot?.contentHash && previousContentHash !== snapshot.contentHash && !previousThreadContentHash && !previousHeadSha;
1076
+ const changed = isFirstObservation || syncResult.ok && snapshot && (legacyAggregateChanged || latestCommentChanged || previousThreadContentHash && snapshot.threadContentHash && previousThreadContentHash !== snapshot.threadContentHash || previousHeadSha && snapshot.headSha && previousHeadSha !== snapshot.headSha || subscription.lastObservedState && snapshot.state && subscription.lastObservedState !== snapshot.state || subscription.lastObservedMergeableState && snapshot.mergeableState && subscription.lastObservedMergeableState !== snapshot.mergeableState || subscription.lastObservedCiState && snapshot.ciState && subscription.lastObservedCiState !== snapshot.ciState || subscription.lastObservedReviewStateHash && snapshot.reviewStateHash && subscription.lastObservedReviewStateHash !== snapshot.reviewStateHash);
1077
+ let shouldKeepSubscription = true;
1078
+ if (changed && snapshot) {
1079
+ const notifications = await this.#sendActivityNotifications({
1080
+ polling: input,
1081
+ subscription,
1082
+ snapshot,
1083
+ previousGithubUpdatedAt,
1084
+ previousContentHash,
1085
+ latestCommentChanged
1086
+ });
1087
+ const primaryNotification = notifications[0];
1088
+ if (primaryNotification) {
1089
+ nextSubscription.lastNotificationAt = now;
1090
+ nextSubscription.lastNotificationKind = primaryNotification.kind;
1091
+ nextSubscription.lastNotificationPriority = primaryNotification.priority;
1092
+ nextSubscription.lastNotificationSummary = primaryNotification.summary;
1093
+ shouldKeepSubscription = notifications.every((notification) => notification.kind !== "pull-request-merged");
1094
+ }
1095
+ }
1096
+ if (shouldKeepSubscription) subscriptions.push(nextSubscription);
1097
+ }
1098
+ await threadStore.saveThread({ thread: {
1099
+ ...loadedThread,
1100
+ id: input.threadId,
1101
+ resourceId: input.resourceId,
1102
+ createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
1103
+ updatedAt: /* @__PURE__ */ new Date(),
1104
+ metadata: setGithubMetadata(loadedThread.metadata, { subscriptions })
1105
+ } });
1106
+ this.#notifySubscriptionsChanged({
1107
+ threadId: input.threadId,
1108
+ resourceId: input.resourceId,
1109
+ subscriptions
1110
+ });
1111
+ if (subscriptions.length === 0) this.stopPollingForThread(input);
1112
+ return subscriptions.length;
1113
+ } catch (error) {
1114
+ throw error;
1115
+ } finally {
1116
+ const latestState = this.#polling.get(key);
1117
+ if (latestState) latestState.running = false;
1118
+ this.#notifyPollingChanged({
1119
+ threadId: input.threadId,
1120
+ resourceId: input.resourceId,
1121
+ running: false
1122
+ });
1123
+ }
1124
+ }
1125
+ #createGithubNotificationInput(input) {
1126
+ const failingChecks = getFailingChecks(input.snapshot);
1127
+ const pendingChecks = getPendingChecks(input.snapshot);
1128
+ const latestCommentExcerpt = input.snapshot.latestCommentBody ? getCommentExcerpt(input.snapshot.latestCommentBody) : void 0;
1129
+ const latestCommentDedupeSuffix = input.notification.kind === "pull-request-activity" && input.snapshot.latestCommentUrl ? `comment:${input.snapshot.latestCommentUrl}:${input.snapshot.latestCommentUpdatedAt ?? ""}` : input.dedupeSuffix;
1130
+ return {
1131
+ source: "github",
1132
+ kind: input.notification.kind,
1133
+ priority: input.notification.priority,
1134
+ summary: input.notification.summary,
1135
+ dedupeKey: `github:${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}:${latestCommentDedupeSuffix}`,
1136
+ coalesceKey: `github:${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}:${input.notification.kind}`,
1137
+ attributes: {
1138
+ owner: input.subscription.owner,
1139
+ repo: input.subscription.repo,
1140
+ number: input.subscription.number,
1141
+ ...input.snapshot.title ? { title: input.snapshot.title } : {},
1142
+ ...input.snapshot.state ? { state: input.snapshot.state } : {},
1143
+ ...input.snapshot.htmlUrl ? { url: input.snapshot.htmlUrl } : {},
1144
+ ...input.snapshot.githubUpdatedAt ? { githubUpdatedAt: input.snapshot.githubUpdatedAt } : {},
1145
+ ...input.previousGithubUpdatedAt ? { previousGithubUpdatedAt: input.previousGithubUpdatedAt } : {},
1146
+ ...input.snapshot.mergeableState ? { mergeableState: input.snapshot.mergeableState } : {},
1147
+ ...input.snapshot.ciState ? { ciState: input.snapshot.ciState } : {},
1148
+ ...input.snapshot.unresolvedReviewThreads !== void 0 ? { unresolvedReviewThreads: input.snapshot.unresolvedReviewThreads } : {},
1149
+ ...input.snapshot.latestCommentAuthor ? { latestCommentAuthor: input.snapshot.latestCommentAuthor } : {},
1150
+ ...latestCommentExcerpt ? { latestCommentExcerpt } : {},
1151
+ ...input.snapshot.latestCommentUrl ? { latestCommentUrl: input.snapshot.latestCommentUrl } : {},
1152
+ ...input.snapshot.latestCommentUpdatedAt ? { latestCommentUpdatedAt: input.snapshot.latestCommentUpdatedAt } : {},
1153
+ ...failingChecks.length > 0 ? { failingChecks: failingChecks.map((check) => check.name).join(", ") } : {},
1154
+ ...pendingChecks.length > 0 ? { pendingChecks: pendingChecks.map((check) => check.name).join(", ") } : {}
1155
+ },
1156
+ metadata: { github: {
1157
+ owner: input.subscription.owner,
1158
+ repo: input.subscription.repo,
1159
+ number: input.subscription.number,
1160
+ title: input.snapshot.title,
1161
+ state: input.snapshot.state,
1162
+ htmlUrl: input.snapshot.htmlUrl,
1163
+ githubUpdatedAt: input.snapshot.githubUpdatedAt,
1164
+ previousGithubUpdatedAt: input.previousGithubUpdatedAt,
1165
+ contentHash: input.snapshot.contentHash,
1166
+ previousContentHash: input.previousContentHash,
1167
+ threadContentHash: input.snapshot.threadContentHash,
1168
+ headSha: input.snapshot.headSha,
1169
+ headRef: input.snapshot.headRef,
1170
+ mergeableState: input.snapshot.mergeableState,
1171
+ ciState: input.snapshot.ciState,
1172
+ closedAt: input.snapshot.closedAt,
1173
+ mergedAt: input.snapshot.mergedAt,
1174
+ unresolvedReviewThreads: input.snapshot.unresolvedReviewThreads,
1175
+ reviewStateHash: input.snapshot.reviewStateHash,
1176
+ latestReviewThreadAt: input.snapshot.latestReviewThreadAt,
1177
+ latestCommentAuthor: input.snapshot.latestCommentAuthor,
1178
+ latestCommentAuthorType: input.snapshot.latestCommentAuthorType,
1179
+ latestCommentIsBot: input.snapshot.latestCommentIsBot,
1180
+ latestCommentExcerpt,
1181
+ latestCommentUrl: input.snapshot.latestCommentUrl,
1182
+ latestCommentUpdatedAt: input.snapshot.latestCommentUpdatedAt,
1183
+ failingChecks,
1184
+ pendingChecks
1185
+ } }
1186
+ };
1187
+ }
1188
+ async #sendGithubNotification(input) {
1189
+ const notificationInput = this.#createGithubNotificationInput(input);
1190
+ const streamOptions = await this.#agentOptions.getNotificationStreamOptions?.(input.target);
1191
+ await input.agent?.sendNotificationSignal?.(notificationInput, streamOptions ? {
1192
+ ...input.target,
1193
+ ifIdle: { streamOptions }
1194
+ } : input.target);
1195
+ }
1196
+ async #sendBaselineNotification(input) {
1197
+ const agent = this.#getNotificationAgent({});
1198
+ if (!agent?.sendNotificationSignal) return;
1199
+ await this.#sendGithubNotification({
1200
+ agent,
1201
+ subscription: input.subscription,
1202
+ snapshot: input.snapshot,
1203
+ notification: classifyGithubBaselineNotification({
1204
+ subscription: input.subscription,
1205
+ snapshot: input.snapshot
1206
+ }),
1207
+ target: {
1208
+ resourceId: input.resourceId,
1209
+ threadId: input.threadId
1210
+ },
1211
+ dedupeSuffix: `baseline:${input.subscription.lastSubscribeSignalId}`
1212
+ });
1213
+ }
1214
+ async #isAuthorizedAuthor(owner, repo, user, metadata = {}) {
1215
+ if (!user) return false;
1216
+ const normalizedUser = user.toLowerCase();
1217
+ if (metadata.isBot === true || metadata.authorType?.toLowerCase() === "bot" || normalizedUser.endsWith("[bot]")) {
1218
+ if ((this.#options.ignoredBots ?? []).some((bot) => bot.toLowerCase() === normalizedUser)) return false;
1219
+ return (this.#options.authorizedBots ?? DEFAULT_AUTHORIZED_BOTS).some((bot) => bot.toLowerCase() === normalizedUser);
1220
+ }
1221
+ const permission = await this.#loadAuthorPermission(owner, repo, user);
1222
+ const authorizedPermissions = this.#options.authorizedPermissions ?? DEFAULT_AUTHORIZED_PERMISSIONS;
1223
+ return !!permission && authorizedPermissions.includes(permission);
1224
+ }
1225
+ async #filterUnauthorizedLatestComment(owner, repo, snapshot) {
1226
+ const comments = snapshot.latestComments?.length ? snapshot.latestComments : [{
1227
+ author: snapshot.latestCommentAuthor,
1228
+ authorType: snapshot.latestCommentAuthorType,
1229
+ isBot: snapshot.latestCommentIsBot,
1230
+ body: snapshot.latestCommentBody,
1231
+ url: snapshot.latestCommentUrl,
1232
+ updatedAt: snapshot.latestCommentUpdatedAt
1233
+ }];
1234
+ if (!comments.some((comment) => comment.author)) return snapshot;
1235
+ if (!comments.some((comment) => comment.body || comment.url || comment.updatedAt)) return snapshot;
1236
+ for (const comment of comments) {
1237
+ if (!await this.#isAuthorizedAuthor(owner, repo, comment.author, {
1238
+ authorType: comment.authorType,
1239
+ isBot: comment.isBot
1240
+ })) continue;
1241
+ return {
1242
+ ...snapshot,
1243
+ latestCommentAuthor: comment.author,
1244
+ latestCommentAuthorType: comment.authorType,
1245
+ latestCommentIsBot: comment.isBot,
1246
+ latestCommentBody: comment.body,
1247
+ latestCommentUrl: comment.url,
1248
+ latestCommentUpdatedAt: comment.updatedAt
1249
+ };
1250
+ }
1251
+ return {
1252
+ ...snapshot,
1253
+ latestCommentAuthor: void 0,
1254
+ latestCommentAuthorType: void 0,
1255
+ latestCommentIsBot: void 0,
1256
+ latestCommentBody: void 0,
1257
+ latestCommentUrl: void 0,
1258
+ latestCommentUpdatedAt: void 0
1259
+ };
1260
+ }
1261
+ async #loadAuthorPermission(owner, repo, user) {
1262
+ const cacheKey = `${owner}/${repo}:${user.toLowerCase()}`;
1263
+ const cached = this.#permissionCache.get(cacheKey);
1264
+ if (cached && cached.expiresAt > Date.now()) return cached.permission;
1265
+ if (cached) this.#permissionCache.delete(cacheKey);
1266
+ try {
1267
+ let permission;
1268
+ if (this.#options.permissionResolver) permission = await this.#options.permissionResolver.getPermission(owner, repo, user);
1269
+ else {
1270
+ const { stdout } = await execFileAsync("gh", [
1271
+ "api",
1272
+ `repos/${owner}/${repo}/collaborators/${user}/permission`,
1273
+ "--jq",
1274
+ ".permission"
1275
+ ]);
1276
+ const raw = stdout.trim();
1277
+ permission = [
1278
+ "admin",
1279
+ "maintain",
1280
+ "write",
1281
+ "triage",
1282
+ "read",
1283
+ "none"
1284
+ ].includes(raw) ? raw : void 0;
1285
+ }
1286
+ if (permission) this.#permissionCache.set(cacheKey, {
1287
+ permission,
1288
+ expiresAt: Date.now() + PERMISSION_CACHE_TTL_MS
1289
+ });
1290
+ return permission;
1291
+ } catch {
1292
+ this.#permissionCache.delete(cacheKey);
1293
+ return;
1294
+ }
1295
+ }
1296
+ async #sendActivityNotifications(input) {
1297
+ const agent = this.#getNotificationAgent(input.polling);
1298
+ if (!agent?.sendNotificationSignal) return [];
1299
+ const notifications = [classifyGithubActivityNotification({
1300
+ subscription: input.subscription,
1301
+ snapshot: input.snapshot
1302
+ })];
1303
+ if (input.latestCommentChanged && notifications[0]?.kind !== "pull-request-activity") notifications.push(classifyGithubCommentActivityNotification({
1304
+ subscription: input.subscription,
1305
+ snapshot: input.snapshot
1306
+ }));
1307
+ const sent = [];
1308
+ const notificationInputs = [];
1309
+ for (const notification of notifications.sort(compareGithubActivityNotifications)) {
1310
+ if (!notification) continue;
1311
+ if (AUTHOR_GATED_NOTIFICATION_KINDS.has(notification.kind)) {
1312
+ if (!await this.#isAuthorizedAuthor(input.subscription.owner, input.subscription.repo, input.snapshot.latestCommentAuthor, {
1313
+ authorType: input.snapshot.latestCommentAuthorType,
1314
+ isBot: input.snapshot.latestCommentIsBot
1315
+ })) continue;
1316
+ }
1317
+ notificationInputs.push(this.#createGithubNotificationInput({
1318
+ subscription: input.subscription,
1319
+ snapshot: input.snapshot,
1320
+ notification,
1321
+ dedupeSuffix: input.snapshot.contentHash ?? input.snapshot.githubUpdatedAt ?? String(Date.now()),
1322
+ previousGithubUpdatedAt: input.previousGithubUpdatedAt,
1323
+ previousContentHash: input.previousContentHash
1324
+ }));
1325
+ sent.push(notification);
1326
+ }
1327
+ if (notificationInputs.length > 0) {
1328
+ const target = {
1329
+ resourceId: input.polling.resourceId,
1330
+ threadId: input.polling.threadId
1331
+ };
1332
+ const streamOptions = await this.#agentOptions.getNotificationStreamOptions?.(target);
1333
+ await agent.sendNotificationSignal(notificationInputs, streamOptions ? {
1334
+ ...target,
1335
+ ifIdle: { streamOptions }
1336
+ } : target);
1337
+ }
1338
+ return sent;
1339
+ }
1340
+ async #subscribe(input) {
1341
+ const { owner, repo } = await this.#resolveRepository(input);
1342
+ const { threadStore, loadedThread } = await this.#loadThread(input);
1343
+ const githubMetadata = getGithubMetadata(loadedThread.metadata);
1344
+ const existingIndex = githubMetadata.subscriptions.findIndex((subscription) => subscription.owner === owner && subscription.repo === repo && subscription.number === input.number);
1345
+ const existing = existingIndex >= 0 ? githubMetadata.subscriptions[existingIndex] : void 0;
1346
+ if (existing?.lastSubscribeSignalId === input.id) return {
1347
+ owner,
1348
+ repo,
1349
+ number: input.number,
1350
+ subscription: existing,
1351
+ alreadyProcessed: true
1352
+ };
1353
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1354
+ const subscription = {
1355
+ owner,
1356
+ repo,
1357
+ number: input.number,
1358
+ subscribedAt: existing?.subscribedAt ?? now,
1359
+ updatedAt: now,
1360
+ lastSubscribeSignalId: input.id,
1361
+ ...existing?.lastSyncAt ? { lastSyncAt: existing.lastSyncAt } : {},
1362
+ ...existing?.lastSyncStatus ? { lastSyncStatus: existing.lastSyncStatus } : {},
1363
+ ...existing?.lastSyncError ? { lastSyncError: existing.lastSyncError } : {},
1364
+ ...existing?.lastObservedGithubUpdatedAt ? { lastObservedGithubUpdatedAt: existing.lastObservedGithubUpdatedAt } : {},
1365
+ ...existing?.lastObservedContentHash ? { lastObservedContentHash: existing.lastObservedContentHash } : {},
1366
+ ...existing?.lastObservedThreadContentHash ? { lastObservedThreadContentHash: existing.lastObservedThreadContentHash } : {},
1367
+ ...existing?.lastObservedHeadSha ? { lastObservedHeadSha: existing.lastObservedHeadSha } : {},
1368
+ ...existing?.lastObservedState ? { lastObservedState: existing.lastObservedState } : {},
1369
+ ...existing?.lastObservedMergeableState ? { lastObservedMergeableState: existing.lastObservedMergeableState } : {},
1370
+ ...existing?.lastObservedCiState ? { lastObservedCiState: existing.lastObservedCiState } : {},
1371
+ ...existing?.lastObservedReviewStateHash ? { lastObservedReviewStateHash: existing.lastObservedReviewStateHash } : {}
1372
+ };
1373
+ let syncResult;
1374
+ let baselineSnapshot;
1375
+ if (this.#options.syncOnSubscribe !== false) {
1376
+ const syncInput = {
1377
+ owner,
1378
+ repo,
1379
+ number: input.number,
1380
+ cwd: this.#options.cwd,
1381
+ abortSignal: input.abortSignal
1382
+ };
1383
+ syncResult = await this.#syncClient.syncPullRequest(syncInput);
1384
+ subscription.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
1385
+ subscription.lastSyncStatus = syncResult.ok ? "success" : "error";
1386
+ if (syncResult.error) subscription.lastSyncError = syncResult.error;
1387
+ else delete subscription.lastSyncError;
1388
+ let snapshot;
1389
+ if (syncResult.ok) try {
1390
+ snapshot = await this.#syncClient.getPullRequestSnapshot?.(syncInput);
1391
+ } catch (error) {
1392
+ subscription.lastSnapshotError = error instanceof Error ? error.message : String(error);
1393
+ }
1394
+ if (snapshot) delete subscription.lastSnapshotError;
1395
+ baselineSnapshot = snapshot;
1396
+ if (snapshot) applySnapshotCursor(subscription, snapshot);
1397
+ } else subscription.lastSyncStatus = "skipped";
1398
+ const subscriptions = [subscription];
1399
+ await threadStore.saveThread({ thread: {
1400
+ ...loadedThread,
1401
+ id: input.threadId,
1402
+ resourceId: input.resourceId,
1403
+ createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
1404
+ updatedAt: /* @__PURE__ */ new Date(),
1405
+ metadata: setGithubMetadata(loadedThread.metadata, { subscriptions })
1406
+ } });
1407
+ this.#notifySubscriptionsChanged({
1408
+ threadId: input.threadId,
1409
+ resourceId: input.resourceId,
1410
+ subscriptions
1411
+ });
1412
+ if (baselineSnapshot) await this.#sendBaselineNotification({
1413
+ threadId: input.threadId,
1414
+ resourceId: input.resourceId,
1415
+ subscription,
1416
+ snapshot: baselineSnapshot
1417
+ });
1418
+ await this.startPollingForThread({
1419
+ threadId: input.threadId,
1420
+ resourceId: input.resourceId
1421
+ });
1422
+ return {
1423
+ owner,
1424
+ repo,
1425
+ number: input.number,
1426
+ subscription,
1427
+ syncResult
1428
+ };
1429
+ }
1430
+ async #unsubscribe(input) {
1431
+ const { owner, repo } = await this.#resolveRepository(input);
1432
+ const { threadStore, loadedThread } = await this.#loadThread(input);
1433
+ const githubMetadata = getGithubMetadata(loadedThread.metadata);
1434
+ const subscriptions = githubMetadata.subscriptions.filter((subscription) => !(subscription.owner === owner && subscription.repo === repo && subscription.number === input.number));
1435
+ const removed = subscriptions.length !== githubMetadata.subscriptions.length;
1436
+ if (removed) {
1437
+ await threadStore.saveThread({ thread: {
1438
+ ...loadedThread,
1439
+ id: input.threadId,
1440
+ resourceId: input.resourceId,
1441
+ createdAt: loadedThread.createdAt ?? /* @__PURE__ */ new Date(),
1442
+ updatedAt: /* @__PURE__ */ new Date(),
1443
+ metadata: setGithubMetadata(loadedThread.metadata, { subscriptions })
1444
+ } });
1445
+ this.#notifySubscriptionsChanged({
1446
+ threadId: input.threadId,
1447
+ resourceId: input.resourceId,
1448
+ subscriptions
1449
+ });
1450
+ if (subscriptions.length === 0) this.stopPollingForThread({
1451
+ threadId: input.threadId,
1452
+ resourceId: input.resourceId
1453
+ });
1454
+ }
1455
+ return {
1456
+ owner,
1457
+ repo,
1458
+ number: input.number,
1459
+ removed,
1460
+ remainingSubscriptions: subscriptions.length
1461
+ };
1462
+ }
1463
+ #findLatestGithubSignal(messages) {
1464
+ const message = messages.at(-1);
1465
+ if (!message) return void 0;
1466
+ const signal = getSignalMetadata(message);
1467
+ if (!signal || signal.tagName !== "github-subscribe-pr" && signal.tagName !== "github-unsubscribe-pr") return;
1468
+ const attributes = isPlainObject(signal.attributes) ? signal.attributes : {};
1469
+ const metadata = isPlainObject(signal.metadata) ? signal.metadata : {};
1470
+ const github = isPlainObject(metadata.github) ? metadata.github : {};
1471
+ const number = readNumber(attributes.number) ?? readNumber(github.number);
1472
+ if (!number) return void 0;
1473
+ return {
1474
+ tagName: String(signal.tagName),
1475
+ id: readString(signal.id) ?? message.id,
1476
+ owner: readString(attributes.owner) ?? readString(github.owner),
1477
+ repo: readString(attributes.repo) ?? readString(github.repo),
1478
+ number
1479
+ };
1480
+ }
1481
+ async #sendStatus(args, signal, status) {
1482
+ await args.sendSignal?.({
1483
+ type: "reactive",
1484
+ tagName: GITHUB_SYNC_STATUS_TAG,
1485
+ contents: status.message,
1486
+ attributes: {
1487
+ status: status.status,
1488
+ owner: signal.owner,
1489
+ repo: signal.repo,
1490
+ number: signal.number
1491
+ },
1492
+ metadata: { github: {
1493
+ action: status.action,
1494
+ status: status.status,
1495
+ owner: signal.owner,
1496
+ repo: signal.repo,
1497
+ number: signal.number
1498
+ } }
1499
+ });
1500
+ }
1367
1501
  };
1368
-
1502
+ //#endregion
1369
1503
  export { GITHUB_SIGNALS_METADATA_KEY, GITHUB_SUBSCRIBE_PR_TAG, GITHUB_SYNC_STATUS_TAG, GITHUB_UNSUBSCRIBE_PR_TAG, GitRemoteRepositoryResolver, GitcrawlSyncClient, GithubSignals, normalizeGithubChecksForSnapshot, sanitizeCommentText };
1370
- //# sourceMappingURL=index.js.map
1504
+
1371
1505
  //# sourceMappingURL=index.js.map