@mastra/github-signals 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # @mastra/github-signals
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Gate GitHub signal notifications behind author permission checks to guard against prompt injection from random commenters. Only comments from users with write access (admin, maintain, write) trigger notifications. Bot comments are opt-in via an allowlist that defaults to CodeRabbit and Devin, with `ignoredBots` still available as an explicit blocklist. Unauthorized latest comments are excluded before notification classification so noisy bot edits do not render in notification metadata or mask the latest authorized comment. Scheduled polls now include comments and detect latest-comment timestamp changes so comment notifications are not lost behind stale or unchanged thread hashes. Comment activity notifications render the latest authorized comment author and excerpt as high-priority GitHub signal updates. ([#17590](https://github.com/mastra-ai/mastra/pull/17590))
8
+
9
+ New options on `GithubSignalsOptions`:
10
+ - `authorizedPermissions` — permission levels that authorize human commenters (default: `['admin', 'maintain', 'write']`)
11
+ - `authorizedBots` — bot logins authorized to trigger notifications (default: `['coderabbitai[bot]', 'devin-ai-integration[bot]']`)
12
+ - `ignoredBots` — bot logins whose comments should NOT trigger notifications, even if authorized
13
+ - `permissionResolver` — injectable resolver for looking up collaborator permissions (default: `gh api`)
14
+
15
+ - Updated dependencies [[`d468acb`](https://github.com/mastra-ai/mastra/commit/d468acb07aec1bb19a2cb0ada8042b05b46746b2), [`575f815`](https://github.com/mastra-ai/mastra/commit/575f815c5c3567b71c0b83cbb7fa98c8253a9d9c), [`34839c1`](https://github.com/mastra-ai/mastra/commit/34839c1910b6964bf59ed0cee58844efebbb684e), [`053735a`](https://github.com/mastra-ai/mastra/commit/053735a75c2c18e23ce34d9468007efa4a45f4c4), [`306909a`](https://github.com/mastra-ai/mastra/commit/306909a693de77d709b38706e2673c9547d24a28), [`5191af8`](https://github.com/mastra-ai/mastra/commit/5191af80c799eea25357c545fc05d91b3883531d), [`43bd3d4`](https://github.com/mastra-ai/mastra/commit/43bd3d421987463fdf35386a45199c49499ed069), [`e6fa79e`](https://github.com/mastra-ai/mastra/commit/e6fa79ec72a2ddffdd25e85270398951e9d552a4), [`904bcdf`](https://github.com/mastra-ai/mastra/commit/904bcdf7b8004aa7be823f9f70ca63580e47e470), [`7f5ee1d`](https://github.com/mastra-ai/mastra/commit/7f5ee1dca46daee8d2817f2ebe49e6335da81956), [`1e9aab5`](https://github.com/mastra-ai/mastra/commit/1e9aab50ff11e6e88fde4d7cbf512c44a9fe8d61), [`2bccba4`](https://github.com/mastra-ai/mastra/commit/2bccba4c03cadc815c2d54cbf4dd43a922140a8d), [`bf8eb6d`](https://github.com/mastra-ai/mastra/commit/bf8eb6d0ec213a403eb9265a594ad283c44ab3dc), [`e9be4e7`](https://github.com/mastra-ai/mastra/commit/e9be4e747ec3d8b65548bff92f9377db06105376), [`493a328`](https://github.com/mastra-ai/mastra/commit/493a328f4346a1deeb9f1e2e44c8f2a3a4d7591b), [`d53cfc2`](https://github.com/mastra-ai/mastra/commit/d53cfc2c7f8d78343a4aa84ec4e129ba25f3325e), [`65799d4`](https://github.com/mastra-ai/mastra/commit/65799d4d549e5ebb9c848fbe3f51ac090f64becf), [`c268c89`](https://github.com/mastra-ai/mastra/commit/c268c89f4c63a93ee474d3cffdf3ea60bf00d4f2), [`34839c1`](https://github.com/mastra-ai/mastra/commit/34839c1910b6964bf59ed0cee58844efebbb684e), [`014e00f`](https://github.com/mastra-ai/mastra/commit/014e00f2b3a597a016b72f9901c6ab27d491f822), [`029a414`](https://github.com/mastra-ai/mastra/commit/029a4141719793bd3e898a39eb5a0466a55f5f3a), [`d468acb`](https://github.com/mastra-ai/mastra/commit/d468acb07aec1bb19a2cb0ada8042b05b46746b2), [`b147b29`](https://github.com/mastra-ai/mastra/commit/b147b2907f0cd1aa812efe6d6e3f58d22e66fc88), [`d371ac1`](https://github.com/mastra-ai/mastra/commit/d371ac1d9820afaaf7cfdbc380a475946a994d8f), [`2bccba4`](https://github.com/mastra-ai/mastra/commit/2bccba4c03cadc815c2d54cbf4dd43a922140a8d), [`0c72f03`](https://github.com/mastra-ai/mastra/commit/0c72f032abb13254df5a7856d64be2f207b8006d), [`cf182b7`](https://github.com/mastra-ai/mastra/commit/cf182b7fb495767946d9840ef29f19cfa906f31f), [`3b45ea9`](https://github.com/mastra-ai/mastra/commit/3b45ea95015557a6cb9d70dc5252af54ab1b78ac), [`a049c2a`](https://github.com/mastra-ai/mastra/commit/a049c2a9dfb41d0ee2e7a28874a88cd64fd5669f), [`f084be1`](https://github.com/mastra-ai/mastra/commit/f084be1fcbe33ad7480913e44d6130c421c0976f), [`b147b29`](https://github.com/mastra-ai/mastra/commit/b147b2907f0cd1aa812efe6d6e3f58d22e66fc88), [`2a96528`](https://github.com/mastra-ai/mastra/commit/2a9652848dfa3c5a2426f952e9d93554c26fd90f), [`f2ab060`](https://github.com/mastra-ai/mastra/commit/f2ab060162bea81505fda553e2cee29c1979fd04), [`5d302c8`](https://github.com/mastra-ai/mastra/commit/5d302c8eda1a6ac74eab5e442c4f64db6cc97a06), [`34839c1`](https://github.com/mastra-ai/mastra/commit/34839c1910b6964bf59ed0cee58844efebbb684e), [`a952852`](https://github.com/mastra-ai/mastra/commit/a952852c971a21fb646cd907c75fcf4443cdc963), [`2656d9c`](https://github.com/mastra-ai/mastra/commit/2656d9c2976d4f3354253bfbbbf9b88a1b2bbf34), [`63e3fe1`](https://github.com/mastra-ai/mastra/commit/63e3fe13cc1ea96f91d7c68aea92f400faf9e4da), [`1d4ce8d`](https://github.com/mastra-ai/mastra/commit/1d4ce8daaa54511f325c1b609d31b8e54009d677), [`8c68372`](https://github.com/mastra-ai/mastra/commit/8c68372e85fe0b066ec12c58bd29ffb93e54c552)]:
16
+ - @mastra/core@1.42.0
17
+
18
+ ## 0.1.1-alpha.0
19
+
20
+ ### Patch Changes
21
+
22
+ - Gate GitHub signal notifications behind author permission checks to guard against prompt injection from random commenters. Only comments from users with write access (admin, maintain, write) trigger notifications. Bot comments are opt-in via an allowlist that defaults to CodeRabbit and Devin, with `ignoredBots` still available as an explicit blocklist. Unauthorized latest comments are excluded before notification classification so noisy bot edits do not render in notification metadata or mask the latest authorized comment. Scheduled polls now include comments and detect latest-comment timestamp changes so comment notifications are not lost behind stale or unchanged thread hashes. Comment activity notifications render the latest authorized comment author and excerpt as high-priority GitHub signal updates. ([#17590](https://github.com/mastra-ai/mastra/pull/17590))
23
+
24
+ New options on `GithubSignalsOptions`:
25
+ - `authorizedPermissions` — permission levels that authorize human commenters (default: `['admin', 'maintain', 'write']`)
26
+ - `authorizedBots` — bot logins authorized to trigger notifications (default: `['coderabbitai[bot]', 'devin-ai-integration[bot]']`)
27
+ - `ignoredBots` — bot logins whose comments should NOT trigger notifications, even if authorized
28
+ - `permissionResolver` — injectable resolver for looking up collaborator permissions (default: `gh api`)
29
+
30
+ - Updated dependencies [[`2bccba4`](https://github.com/mastra-ai/mastra/commit/2bccba4c03cadc815c2d54cbf4dd43a922140a8d), [`2bccba4`](https://github.com/mastra-ai/mastra/commit/2bccba4c03cadc815c2d54cbf4dd43a922140a8d), [`f2ab060`](https://github.com/mastra-ai/mastra/commit/f2ab060162bea81505fda553e2cee29c1979fd04), [`5d302c8`](https://github.com/mastra-ai/mastra/commit/5d302c8eda1a6ac74eab5e442c4f64db6cc97a06)]:
31
+ - @mastra/core@1.42.0-alpha.1
package/dist/index.cjs CHANGED
@@ -26,6 +26,10 @@ var GITHUB_SUBSCRIBE_PR_TAG = "github-subscribe-pr";
26
26
  var GITHUB_UNSUBSCRIBE_PR_TAG = "github-unsubscribe-pr";
27
27
  var GITHUB_SYNC_STATUS_TAG = "github-sync-status";
28
28
  var GITHUB_SIGNALS_METADATA_KEY = "githubSignals";
29
+ var DEFAULT_AUTHORIZED_PERMISSIONS = ["admin", "maintain", "write"];
30
+ var DEFAULT_AUTHORIZED_BOTS = ["coderabbitai[bot]", "devin-ai-integration[bot]"];
31
+ var PERMISSION_CACHE_TTL_MS = 5 * 60 * 1e3;
32
+ var AUTHOR_GATED_NOTIFICATION_KINDS = /* @__PURE__ */ new Set(["pull-request-activity", "pull-request-review-activity"]);
29
33
  var createGithubTool = tools.createTool;
30
34
  function isPlainObject(value) {
31
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -146,6 +150,36 @@ function getPrLabel(subscription, snapshot) {
146
150
  function getMergedNotificationSummary(label) {
147
151
  return `${label} was merged. This thread has been automatically unsubscribed from this PR. Resubscribe if you still need updates.`;
148
152
  }
153
+ function getCommentExcerpt(body) {
154
+ const excerpt = body.replace(/\s+/g, " ").trim();
155
+ return excerpt.length > 240 ? `${excerpt.slice(0, 237)}...` : excerpt;
156
+ }
157
+ function getCommentNotificationSummary(pr, snapshot) {
158
+ if (!snapshot.latestCommentAuthor || !snapshot.latestCommentBody) return void 0;
159
+ return `${snapshot.latestCommentAuthor} commented on ${pr}: ${getCommentExcerpt(snapshot.latestCommentBody)}`;
160
+ }
161
+ var githubActivityNotificationPriority = {
162
+ high: 0,
163
+ medium: 1
164
+ };
165
+ function getGithubActivityNotificationRank(notification) {
166
+ return notification.kind === "pull-request-activity" ? 0 : 1;
167
+ }
168
+ function compareGithubActivityNotifications(a, b) {
169
+ if (!a && !b) return 0;
170
+ if (!a) return 1;
171
+ if (!b) return -1;
172
+ const priorityComparison = githubActivityNotificationPriority[a.priority] - githubActivityNotificationPriority[b.priority];
173
+ if (priorityComparison !== 0) return priorityComparison;
174
+ return getGithubActivityNotificationRank(a) - getGithubActivityNotificationRank(b);
175
+ }
176
+ function classifyGithubCommentActivityNotification(input) {
177
+ if (isBotOnlyActivity(input.snapshot)) return void 0;
178
+ const pr = `${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}`;
179
+ const summary = getCommentNotificationSummary(pr, input.snapshot);
180
+ if (!summary) return void 0;
181
+ return { kind: "pull-request-activity", priority: "high", summary };
182
+ }
149
183
  function getCheckUpdatedTime(check) {
150
184
  const value = check.updatedAt ? Date.parse(check.updatedAt) : Number.NaN;
151
185
  return Number.isFinite(value) ? value : 0;
@@ -269,10 +303,11 @@ function classifyGithubActivityNotification(input) {
269
303
  }
270
304
  if (input.snapshot.ciState === "pending" && input.subscription.lastObservedCiState === "pending") return void 0;
271
305
  if (isBotOnlyActivity(input.snapshot)) return void 0;
306
+ const commentSummary = getCommentNotificationSummary(pr, input.snapshot);
272
307
  return {
273
308
  kind: "pull-request-activity",
274
- priority: "medium",
275
- summary: `${pr} has new activity${input.snapshot.title ? `: ${input.snapshot.title}` : ""}`
309
+ priority: commentSummary ? "high" : "medium",
310
+ summary: commentSummary ?? `${pr} has new activity${input.snapshot.title ? `: ${input.snapshot.title}` : ""}`
276
311
  };
277
312
  }
278
313
  function classifyGithubBaselineNotification(input) {
@@ -397,13 +432,15 @@ var GitcrawlSyncClient = class {
397
432
  join threads t on t.id=rt.thread_id
398
433
  join repositories r on r.id=t.repo_id
399
434
  where r.owner=${owner} and r.name=${repo} and t.number=${number} and rt.is_resolved=0`);
400
- const [latestComment] = await queryGitcrawlDb(`select c.author_login, c.author_type, c.is_bot
435
+ 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,
436
+ coalesce(c.updated_at_gh, c.created_at_gh) as updated_at
401
437
  from comments c
402
438
  join threads t on t.id=c.thread_id
403
439
  join repositories r on r.id=t.repo_id
404
440
  where r.owner=${owner} and r.name=${repo} and t.number=${number}
405
441
  order by coalesce(c.updated_at_gh, c.created_at_gh) desc
406
- limit 1`);
442
+ limit 20`);
443
+ const latestComment = latestComments[0];
407
444
  const checks = normalizeGithubChecksForSnapshot({
408
445
  checkRows: checkRows.map((row) => ({
409
446
  source: "check",
@@ -465,7 +502,18 @@ var GitcrawlSyncClient = class {
465
502
  latestReviewThreadAt: readString(reviewState?.latest_review_thread_at),
466
503
  latestCommentAuthor: readString(latestComment?.author_login),
467
504
  latestCommentAuthorType: readString(latestComment?.author_type),
468
- latestCommentIsBot: latestComment?.is_bot === 1
505
+ latestCommentIsBot: latestComment?.is_bot === 1,
506
+ latestCommentBody: readString(latestComment?.body),
507
+ latestCommentUrl: readString(latestComment?.html_url),
508
+ latestCommentUpdatedAt: readString(latestComment?.updated_at),
509
+ latestComments: latestComments.map((comment) => ({
510
+ author: readString(comment.author_login),
511
+ authorType: readString(comment.author_type),
512
+ isBot: comment.is_bot === 1,
513
+ body: readString(comment.body),
514
+ url: readString(comment.html_url),
515
+ updatedAt: readString(comment.updated_at)
516
+ }))
469
517
  };
470
518
  } catch {
471
519
  return void 0;
@@ -520,9 +568,11 @@ var GithubSignals = class extends signals.SignalProvider {
520
568
  #syncClient;
521
569
  #repositoryResolver;
522
570
  #polling = /* @__PURE__ */ new Map();
571
+ #permissionCache = /* @__PURE__ */ new Map();
523
572
  #agent;
524
573
  #agentOptions = {};
525
574
  #subscriptionsChangedHandler;
575
+ #pollingChangedHandler;
526
576
  constructor(options = {}) {
527
577
  super();
528
578
  this.#options = options;
@@ -557,6 +607,9 @@ var GithubSignals = class extends signals.SignalProvider {
557
607
  onSubscriptionsChanged(handler) {
558
608
  this.#subscriptionsChangedHandler = handler;
559
609
  }
610
+ onPollingChanged(handler) {
611
+ this.#pollingChangedHandler = handler;
612
+ }
560
613
  __registerMastra(mastra) {
561
614
  super.__registerMastra(mastra);
562
615
  this.#ghMastra = mastra;
@@ -595,15 +648,13 @@ var GithubSignals = class extends signals.SignalProvider {
595
648
  this.#polling.delete(pollingKey);
596
649
  }
597
650
  if (this.#polling.has(key)) return true;
598
- let scheduledPollCount = options.pollImmediately ? 1 : 0;
599
651
  const runPoll = (pollOptions = {}) => {
600
652
  void this.#pollThread(input, pollOptions).catch((error) => {
601
653
  console.warn("GitHub PR polling failed:", error);
602
654
  });
603
655
  };
604
656
  const timer = setInterval(() => {
605
- scheduledPollCount += 1;
606
- runPoll({ includeComments: scheduledPollCount % 2 === 1 });
657
+ runPoll({ includeComments: true });
607
658
  }, this.#options.pollIntervalMs ?? 3e5);
608
659
  if (options.pollImmediately) runPoll({ includeComments: true });
609
660
  timer.unref?.();
@@ -620,6 +671,9 @@ var GithubSignals = class extends signals.SignalProvider {
620
671
  isPollingThread(input) {
621
672
  return this.#polling.has(this.#pollingKey(input));
622
673
  }
674
+ isPollingThreadRunning(input) {
675
+ return this.#polling.get(this.#pollingKey(input))?.running ?? false;
676
+ }
623
677
  getPollIntervalMs() {
624
678
  return this.#options.pollIntervalMs ?? 3e5;
625
679
  }
@@ -711,6 +765,10 @@ var GithubSignals = class extends signals.SignalProvider {
711
765
  }
712
766
  #createTools(args) {
713
767
  const threadContext = this.#getThreadContext(args);
768
+ const getExecutionThreadContext = (context) => ({
769
+ threadId: context?.agent?.threadId ?? threadContext.threadId,
770
+ resourceId: context?.agent?.resourceId ?? threadContext.resourceId
771
+ });
714
772
  return {
715
773
  ...args.tools,
716
774
  github_subscribe_pr: createGithubTool({
@@ -721,14 +779,15 @@ var GithubSignals = class extends signals.SignalProvider {
721
779
  owner: z__default.default.string().optional(),
722
780
  repo: z__default.default.string().optional()
723
781
  }),
724
- execute: async (input) => {
782
+ execute: async (input, context) => {
783
+ const executionThreadContext = getExecutionThreadContext(context);
725
784
  const result = await this.#subscribe({
726
785
  id: `github-tool-subscribe-${crypto.randomUUID()}`,
727
786
  owner: input.owner,
728
787
  repo: input.repo,
729
788
  number: input.number,
730
- threadId: threadContext.threadId,
731
- resourceId: threadContext.resourceId
789
+ threadId: executionThreadContext.threadId,
790
+ resourceId: executionThreadContext.resourceId
732
791
  });
733
792
  return {
734
793
  subscribed: true,
@@ -748,14 +807,15 @@ var GithubSignals = class extends signals.SignalProvider {
748
807
  owner: z__default.default.string().optional(),
749
808
  repo: z__default.default.string().optional()
750
809
  }),
751
- execute: async (input) => {
810
+ execute: async (input, context) => {
811
+ const executionThreadContext = getExecutionThreadContext(context);
752
812
  const result = await this.#unsubscribe({
753
813
  id: `github-tool-unsubscribe-${crypto.randomUUID()}`,
754
814
  owner: input.owner,
755
815
  repo: input.repo,
756
816
  number: input.number,
757
- threadId: threadContext.threadId,
758
- resourceId: threadContext.resourceId
817
+ threadId: executionThreadContext.threadId,
818
+ resourceId: executionThreadContext.resourceId
759
819
  });
760
820
  return {
761
821
  unsubscribed: result.removed ?? false,
@@ -805,6 +865,9 @@ var GithubSignals = class extends signals.SignalProvider {
805
865
  #notifySubscriptionsChanged(input) {
806
866
  this.#subscriptionsChangedHandler?.(input);
807
867
  }
868
+ #notifyPollingChanged(input) {
869
+ this.#pollingChangedHandler?.(input);
870
+ }
808
871
  async #pollThread(input, options = {}) {
809
872
  const key = this.#pollingKey(input);
810
873
  const state = this.#polling.get(key);
@@ -812,6 +875,7 @@ var GithubSignals = class extends signals.SignalProvider {
812
875
  return 0;
813
876
  }
814
877
  if (state) state.running = true;
878
+ this.#notifyPollingChanged({ threadId: input.threadId, resourceId: input.resourceId, running: true });
815
879
  try {
816
880
  const { threadStore, loadedThread } = await this.#loadThread(input);
817
881
  const githubMetadata = getGithubMetadata(loadedThread.metadata);
@@ -830,7 +894,9 @@ var GithubSignals = class extends signals.SignalProvider {
830
894
  includeComments: options.includeComments
831
895
  };
832
896
  const syncResult = await this.#syncClient.syncPullRequest(syncInput);
833
- const snapshot = syncResult.ok ? await this.#syncClient.getPullRequestSnapshot?.(syncInput) : void 0;
897
+ let snapshot = syncResult.ok ? await this.#syncClient.getPullRequestSnapshot?.(syncInput) : void 0;
898
+ if (snapshot)
899
+ snapshot = await this.#filterUnauthorizedLatestComment(subscription.owner, subscription.repo, snapshot);
834
900
  const nextSubscription = {
835
901
  ...subscription,
836
902
  updatedAt: now,
@@ -843,25 +909,28 @@ var GithubSignals = class extends signals.SignalProvider {
843
909
  const previousContentHash = subscription.lastObservedContentHash;
844
910
  const previousThreadContentHash = subscription.lastObservedThreadContentHash;
845
911
  const previousHeadSha = subscription.lastObservedHeadSha;
912
+ const latestCommentChanged = !!previousGithubUpdatedAt && !!snapshot?.latestCommentUpdatedAt && Date.parse(snapshot.latestCommentUpdatedAt) > Date.parse(previousGithubUpdatedAt);
846
913
  if (snapshot) applySnapshotCursor(nextSubscription, snapshot);
847
914
  const isFirstObservation = syncResult.ok && snapshot && !previousGithubUpdatedAt && !previousContentHash;
848
915
  const legacyAggregateChanged = previousContentHash && snapshot?.contentHash && previousContentHash !== snapshot.contentHash && !previousThreadContentHash && !previousHeadSha;
849
- const changed = isFirstObservation || syncResult.ok && snapshot && (legacyAggregateChanged || 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);
916
+ 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);
850
917
  let shouldKeepSubscription = true;
851
- if (changed) {
852
- const notification = await this.#sendActivityNotification({
918
+ if (changed && snapshot) {
919
+ const notifications = await this.#sendActivityNotifications({
853
920
  polling: input,
854
921
  subscription,
855
922
  snapshot,
856
923
  previousGithubUpdatedAt,
857
- previousContentHash
924
+ previousContentHash,
925
+ latestCommentChanged
858
926
  });
859
- if (notification) {
927
+ const primaryNotification = notifications[0];
928
+ if (primaryNotification) {
860
929
  nextSubscription.lastNotificationAt = now;
861
- nextSubscription.lastNotificationKind = notification.kind;
862
- nextSubscription.lastNotificationPriority = notification.priority;
863
- nextSubscription.lastNotificationSummary = notification.summary;
864
- shouldKeepSubscription = notification.kind !== "pull-request-merged";
930
+ nextSubscription.lastNotificationKind = primaryNotification.kind;
931
+ nextSubscription.lastNotificationPriority = primaryNotification.priority;
932
+ nextSubscription.lastNotificationSummary = primaryNotification.summary;
933
+ shouldKeepSubscription = notifications.every((notification) => notification.kind !== "pull-request-merged");
865
934
  }
866
935
  }
867
936
  if (shouldKeepSubscription) subscriptions.push(nextSubscription);
@@ -884,17 +953,20 @@ var GithubSignals = class extends signals.SignalProvider {
884
953
  } finally {
885
954
  const latestState = this.#polling.get(key);
886
955
  if (latestState) latestState.running = false;
956
+ this.#notifyPollingChanged({ threadId: input.threadId, resourceId: input.resourceId, running: false });
887
957
  }
888
958
  }
889
- async #sendGithubNotification(input) {
959
+ #createGithubNotificationInput(input) {
890
960
  const failingChecks = getFailingChecks(input.snapshot);
891
961
  const pendingChecks = getPendingChecks(input.snapshot);
962
+ const latestCommentExcerpt = input.snapshot.latestCommentBody ? getCommentExcerpt(input.snapshot.latestCommentBody) : void 0;
963
+ const latestCommentDedupeSuffix = input.notification.kind === "pull-request-activity" && input.snapshot.latestCommentUrl ? `comment:${input.snapshot.latestCommentUrl}:${input.snapshot.latestCommentUpdatedAt ?? ""}` : input.dedupeSuffix;
892
964
  const notificationInput = {
893
965
  source: "github",
894
966
  kind: input.notification.kind,
895
967
  priority: input.notification.priority,
896
968
  summary: input.notification.summary,
897
- dedupeKey: `github:${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}:${input.dedupeSuffix}`,
969
+ dedupeKey: `github:${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}:${latestCommentDedupeSuffix}`,
898
970
  coalesceKey: `github:${input.subscription.owner}/${input.subscription.repo}#${input.subscription.number}:${input.notification.kind}`,
899
971
  attributes: {
900
972
  owner: input.subscription.owner,
@@ -908,6 +980,10 @@ var GithubSignals = class extends signals.SignalProvider {
908
980
  ...input.snapshot.mergeableState ? { mergeableState: input.snapshot.mergeableState } : {},
909
981
  ...input.snapshot.ciState ? { ciState: input.snapshot.ciState } : {},
910
982
  ...input.snapshot.unresolvedReviewThreads !== void 0 ? { unresolvedReviewThreads: input.snapshot.unresolvedReviewThreads } : {},
983
+ ...input.snapshot.latestCommentAuthor ? { latestCommentAuthor: input.snapshot.latestCommentAuthor } : {},
984
+ ...latestCommentExcerpt ? { latestCommentExcerpt } : {},
985
+ ...input.snapshot.latestCommentUrl ? { latestCommentUrl: input.snapshot.latestCommentUrl } : {},
986
+ ...input.snapshot.latestCommentUpdatedAt ? { latestCommentUpdatedAt: input.snapshot.latestCommentUpdatedAt } : {},
911
987
  ...failingChecks.length > 0 ? { failingChecks: failingChecks.map((check) => check.name).join(", ") } : {},
912
988
  ...pendingChecks.length > 0 ? { pendingChecks: pendingChecks.map((check) => check.name).join(", ") } : {}
913
989
  },
@@ -936,11 +1012,19 @@ var GithubSignals = class extends signals.SignalProvider {
936
1012
  latestCommentAuthor: input.snapshot.latestCommentAuthor,
937
1013
  latestCommentAuthorType: input.snapshot.latestCommentAuthorType,
938
1014
  latestCommentIsBot: input.snapshot.latestCommentIsBot,
1015
+ latestCommentBody: input.snapshot.latestCommentBody,
1016
+ latestCommentExcerpt,
1017
+ latestCommentUrl: input.snapshot.latestCommentUrl,
1018
+ latestCommentUpdatedAt: input.snapshot.latestCommentUpdatedAt,
939
1019
  failingChecks,
940
1020
  pendingChecks
941
1021
  }
942
1022
  }
943
1023
  };
1024
+ return notificationInput;
1025
+ }
1026
+ async #sendGithubNotification(input) {
1027
+ const notificationInput = this.#createGithubNotificationInput(input);
944
1028
  const streamOptions = await this.#agentOptions.getNotificationStreamOptions?.(input.target);
945
1029
  await input.agent?.sendNotificationSignal?.(
946
1030
  notificationInput,
@@ -959,25 +1043,144 @@ var GithubSignals = class extends signals.SignalProvider {
959
1043
  dedupeSuffix: `baseline:${input.subscription.lastSubscribeSignalId}`
960
1044
  });
961
1045
  }
962
- async #sendActivityNotification(input) {
1046
+ async #isAuthorizedAuthor(owner, repo, user, metadata = {}) {
1047
+ if (!user) return false;
1048
+ const normalizedUser = user.toLowerCase();
1049
+ const isBot = metadata.isBot === true || metadata.authorType?.toLowerCase() === "bot" || normalizedUser.endsWith("[bot]");
1050
+ if (isBot) {
1051
+ const ignoredBots = this.#options.ignoredBots ?? [];
1052
+ if (ignoredBots.some((bot) => bot.toLowerCase() === normalizedUser)) return false;
1053
+ const authorizedBots = this.#options.authorizedBots ?? DEFAULT_AUTHORIZED_BOTS;
1054
+ return authorizedBots.some((bot) => bot.toLowerCase() === normalizedUser);
1055
+ }
1056
+ const permission = await this.#loadAuthorPermission(owner, repo, user);
1057
+ const authorizedPermissions = this.#options.authorizedPermissions ?? DEFAULT_AUTHORIZED_PERMISSIONS;
1058
+ return !!permission && authorizedPermissions.includes(permission);
1059
+ }
1060
+ async #filterUnauthorizedLatestComment(owner, repo, snapshot) {
1061
+ const comments = snapshot.latestComments?.length ? snapshot.latestComments : [
1062
+ {
1063
+ author: snapshot.latestCommentAuthor,
1064
+ authorType: snapshot.latestCommentAuthorType,
1065
+ isBot: snapshot.latestCommentIsBot,
1066
+ body: snapshot.latestCommentBody,
1067
+ url: snapshot.latestCommentUrl,
1068
+ updatedAt: snapshot.latestCommentUpdatedAt
1069
+ }
1070
+ ];
1071
+ if (!comments.some((comment) => comment.author)) return snapshot;
1072
+ if (!comments.some((comment) => comment.body || comment.url || comment.updatedAt)) return snapshot;
1073
+ for (const comment of comments) {
1074
+ if (!await this.#isAuthorizedAuthor(owner, repo, comment.author, {
1075
+ authorType: comment.authorType,
1076
+ isBot: comment.isBot
1077
+ })) {
1078
+ continue;
1079
+ }
1080
+ return {
1081
+ ...snapshot,
1082
+ latestCommentAuthor: comment.author,
1083
+ latestCommentAuthorType: comment.authorType,
1084
+ latestCommentIsBot: comment.isBot,
1085
+ latestCommentBody: comment.body,
1086
+ latestCommentUrl: comment.url,
1087
+ latestCommentUpdatedAt: comment.updatedAt
1088
+ };
1089
+ }
1090
+ return {
1091
+ ...snapshot,
1092
+ latestCommentAuthor: void 0,
1093
+ latestCommentAuthorType: void 0,
1094
+ latestCommentIsBot: void 0,
1095
+ latestCommentBody: void 0,
1096
+ latestCommentUrl: void 0,
1097
+ latestCommentUpdatedAt: void 0
1098
+ };
1099
+ }
1100
+ async #loadAuthorPermission(owner, repo, user) {
1101
+ const cacheKey = `${owner}/${repo}:${user.toLowerCase()}`;
1102
+ const cached = this.#permissionCache.get(cacheKey);
1103
+ if (cached && cached.expiresAt > Date.now()) return cached.permission;
1104
+ if (cached) this.#permissionCache.delete(cacheKey);
1105
+ try {
1106
+ let permission;
1107
+ if (this.#options.permissionResolver) {
1108
+ permission = await this.#options.permissionResolver.getPermission(owner, repo, user);
1109
+ } else {
1110
+ const { stdout } = await execFileAsync("gh", [
1111
+ "api",
1112
+ `repos/${owner}/${repo}/collaborators/${user}/permission`,
1113
+ "--jq",
1114
+ ".permission"
1115
+ ]);
1116
+ const raw = stdout.trim();
1117
+ permission = ["admin", "maintain", "write", "triage", "read", "none"].includes(
1118
+ raw
1119
+ ) ? raw : void 0;
1120
+ }
1121
+ if (permission) {
1122
+ this.#permissionCache.set(cacheKey, { permission, expiresAt: Date.now() + PERMISSION_CACHE_TTL_MS });
1123
+ }
1124
+ return permission;
1125
+ } catch {
1126
+ this.#permissionCache.delete(cacheKey);
1127
+ return void 0;
1128
+ }
1129
+ }
1130
+ async #sendActivityNotifications(input) {
963
1131
  const agent = this.#getNotificationAgent(input.polling);
964
- if (!agent?.sendNotificationSignal) return void 0;
965
- const notification = classifyGithubActivityNotification({
966
- subscription: input.subscription,
967
- snapshot: input.snapshot
968
- });
969
- if (!notification) return void 0;
970
- await this.#sendGithubNotification({
971
- agent,
972
- subscription: input.subscription,
973
- snapshot: input.snapshot,
974
- notification,
975
- target: { resourceId: input.polling.resourceId, threadId: input.polling.threadId },
976
- dedupeSuffix: input.snapshot.contentHash ?? input.snapshot.githubUpdatedAt ?? String(Date.now()),
977
- previousGithubUpdatedAt: input.previousGithubUpdatedAt,
978
- previousContentHash: input.previousContentHash
979
- });
980
- return notification;
1132
+ if (!agent?.sendNotificationSignal) return [];
1133
+ const notifications = [
1134
+ classifyGithubActivityNotification({
1135
+ subscription: input.subscription,
1136
+ snapshot: input.snapshot
1137
+ })
1138
+ ];
1139
+ if (input.latestCommentChanged && notifications[0]?.kind !== "pull-request-activity") {
1140
+ notifications.push(
1141
+ classifyGithubCommentActivityNotification({
1142
+ subscription: input.subscription,
1143
+ snapshot: input.snapshot
1144
+ })
1145
+ );
1146
+ }
1147
+ const sent = [];
1148
+ const notificationInputs = [];
1149
+ for (const notification of notifications.sort(compareGithubActivityNotifications)) {
1150
+ if (!notification) continue;
1151
+ if (AUTHOR_GATED_NOTIFICATION_KINDS.has(notification.kind)) {
1152
+ const authorized = await this.#isAuthorizedAuthor(
1153
+ input.subscription.owner,
1154
+ input.subscription.repo,
1155
+ input.snapshot.latestCommentAuthor,
1156
+ {
1157
+ authorType: input.snapshot.latestCommentAuthorType,
1158
+ isBot: input.snapshot.latestCommentIsBot
1159
+ }
1160
+ );
1161
+ if (!authorized) continue;
1162
+ }
1163
+ notificationInputs.push(
1164
+ this.#createGithubNotificationInput({
1165
+ subscription: input.subscription,
1166
+ snapshot: input.snapshot,
1167
+ notification,
1168
+ dedupeSuffix: input.snapshot.contentHash ?? input.snapshot.githubUpdatedAt ?? String(Date.now()),
1169
+ previousGithubUpdatedAt: input.previousGithubUpdatedAt,
1170
+ previousContentHash: input.previousContentHash
1171
+ })
1172
+ );
1173
+ sent.push(notification);
1174
+ }
1175
+ if (notificationInputs.length > 0) {
1176
+ const target = { resourceId: input.polling.resourceId, threadId: input.polling.threadId };
1177
+ const streamOptions = await this.#agentOptions.getNotificationStreamOptions?.(target);
1178
+ await agent.sendNotificationSignal(
1179
+ notificationInputs,
1180
+ streamOptions ? { ...target, ifIdle: { streamOptions } } : target
1181
+ );
1182
+ }
1183
+ return sent;
981
1184
  }
982
1185
  async #subscribe(input) {
983
1186
  const { owner, repo } = await this.#resolveRepository(input);