@cursor/july 0.1.74 → 0.1.76

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.
Files changed (140) hide show
  1. package/dist/channels/checks.d.ts +111 -0
  2. package/dist/channels/checks.d.ts.map +1 -0
  3. package/dist/channels/checks.js +86 -0
  4. package/dist/channels/deployments/deployments-channel.d.ts +44 -0
  5. package/dist/channels/deployments/deployments-channel.d.ts.map +1 -0
  6. package/dist/channels/deployments/deployments-channel.js +92 -0
  7. package/dist/channels/deployments/index.d.ts +26 -0
  8. package/dist/channels/deployments/index.d.ts.map +1 -0
  9. package/dist/channels/deployments/index.js +25 -0
  10. package/dist/channels/deployments/types.d.ts +69 -0
  11. package/dist/channels/deployments/types.d.ts.map +1 -0
  12. package/dist/channels/deployments/types.js +6 -0
  13. package/dist/channels/github/checks.d.ts +13 -48
  14. package/dist/channels/github/checks.d.ts.map +1 -1
  15. package/dist/channels/github/checks.js +56 -64
  16. package/dist/channels/origin/api.d.ts +100 -0
  17. package/dist/channels/origin/api.d.ts.map +1 -0
  18. package/dist/channels/origin/api.js +225 -0
  19. package/dist/channels/origin/binding.d.ts +44 -0
  20. package/dist/channels/origin/binding.d.ts.map +1 -0
  21. package/dist/channels/origin/binding.js +45 -0
  22. package/dist/channels/origin/checks.d.ts +107 -0
  23. package/dist/channels/origin/checks.d.ts.map +1 -0
  24. package/dist/channels/origin/checks.js +145 -0
  25. package/dist/channels/origin/index.d.ts +3 -0
  26. package/dist/channels/origin/index.d.ts.map +1 -1
  27. package/dist/channels/origin/index.js +3 -0
  28. package/dist/channels/origin/origin-channel.d.ts +4 -4
  29. package/dist/channels/origin/origin-channel.d.ts.map +1 -1
  30. package/dist/channels/origin/origin-channel.js +12 -2
  31. package/dist/channels/origin/types.d.ts +36 -0
  32. package/dist/channels/origin/types.d.ts.map +1 -1
  33. package/dist/channels.d.ts +1 -0
  34. package/dist/channels.d.ts.map +1 -1
  35. package/dist/channels.js +1 -0
  36. package/dist/docs/404.html +2 -2
  37. package/dist/docs/ab.html +3 -3
  38. package/dist/docs/assets/{app.BETd-WqU.js → app.b8bhB2SV.js} +1 -1
  39. package/dist/docs/assets/chunks/@localSearchIndexroot.DGZeXVqn.js +1 -0
  40. package/dist/docs/assets/chunks/{VPLocalSearchBox.wEaGEmV9.js → VPLocalSearchBox.lLSeIyWw.js} +1 -1
  41. package/dist/docs/assets/chunks/{theme.DNDicFOP.js → theme.C_owesa-.js} +2 -2
  42. package/dist/docs/assets/{reference_channels.md.MN8iDy8V.js → reference_channels.md.G5GysZ7e.js} +2 -2
  43. package/dist/docs/assets/{reference_channels.md.MN8iDy8V.lean.js → reference_channels.md.G5GysZ7e.lean.js} +1 -1
  44. package/dist/docs/building-with-agents.html +3 -3
  45. package/dist/docs/concepts.html +3 -3
  46. package/dist/docs/deployment.html +3 -3
  47. package/dist/docs/evals.html +3 -3
  48. package/dist/docs/example-agents/approval-buddy.html +3 -3
  49. package/dist/docs/example-agents/benny.html +3 -3
  50. package/dist/docs/example-agents/bugbot.html +3 -3
  51. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  52. package/dist/docs/example-agents/codeowners-review.html +3 -3
  53. package/dist/docs/example-agents/concierge.html +3 -3
  54. package/dist/docs/example-agents/fsd.html +3 -3
  55. package/dist/docs/example-agents/index.html +3 -3
  56. package/dist/docs/example-agents/knowledge-base.html +3 -3
  57. package/dist/docs/example-agents/oncall.html +3 -3
  58. package/dist/docs/example-agents/security-reviewer.html +3 -3
  59. package/dist/docs/example-agents/slack-agent.html +3 -3
  60. package/dist/docs/example-agents/weather-agent.html +3 -3
  61. package/dist/docs/guides/agent-to-agent.html +3 -3
  62. package/dist/docs/guides/cloud-runtime.html +3 -3
  63. package/dist/docs/guides/convert-automation.html +3 -3
  64. package/dist/docs/guides/github.html +3 -3
  65. package/dist/docs/guides/human-in-the-loop.html +3 -3
  66. package/dist/docs/guides/mcp-oauth.html +3 -3
  67. package/dist/docs/guides/opentelemetry.html +3 -3
  68. package/dist/docs/guides/slack.html +3 -3
  69. package/dist/docs/guides/webhooks.html +3 -3
  70. package/dist/docs/hashmap.json +1 -1
  71. package/dist/docs/hillclimbing.html +3 -3
  72. package/dist/docs/index.html +3 -3
  73. package/dist/docs/quickstart.html +3 -3
  74. package/dist/docs/reference/agent-config.html +3 -3
  75. package/dist/docs/reference/artifacts.html +3 -3
  76. package/dist/docs/reference/channels.html +5 -5
  77. package/dist/docs/reference/cli.html +3 -3
  78. package/dist/docs/reference/connections.html +3 -3
  79. package/dist/docs/reference/hooks.html +3 -3
  80. package/dist/docs/reference/http-api.html +3 -3
  81. package/dist/docs/reference/instructions.html +3 -3
  82. package/dist/docs/reference/playground.html +3 -3
  83. package/dist/docs/reference/project-layout.html +3 -3
  84. package/dist/docs/reference/prompt.html +3 -3
  85. package/dist/docs/reference/schedules.html +3 -3
  86. package/dist/docs/reference/sessions.html +3 -3
  87. package/dist/docs/reference/skills.html +3 -3
  88. package/dist/docs/reference/subagents.html +3 -3
  89. package/dist/docs/reference/tools.html +3 -3
  90. package/dist/docs/scaffolding-agents.html +3 -3
  91. package/dist/docs/storage.html +3 -3
  92. package/dist/docs/templates/agentic-owners.html +3 -3
  93. package/dist/docs/templates/demo.html +3 -3
  94. package/dist/docs/templates/pr-autofixer.html +3 -3
  95. package/dist/docs/templates/security-reviewer.html +3 -3
  96. package/dist/docs/templates/triage.html +3 -3
  97. package/dist/docs/troubleshooting.html +3 -3
  98. package/dist/internal/authored-alias-hooks.d.ts +12 -0
  99. package/dist/internal/authored-alias-hooks.d.ts.map +1 -1
  100. package/dist/internal/authored-alias-hooks.js +60 -8
  101. package/dist/internal/authored-loaders.d.ts +5 -3
  102. package/dist/internal/authored-loaders.d.ts.map +1 -1
  103. package/dist/internal/authored-loaders.js +13 -4
  104. package/dist/internal/cursor-deployment-relay.d.ts +77 -0
  105. package/dist/internal/cursor-deployment-relay.d.ts.map +1 -0
  106. package/dist/internal/cursor-deployment-relay.js +201 -0
  107. package/dist/internal/deployment-scm-event-stream.js +4 -4
  108. package/dist/internal/distribution.d.ts.map +1 -1
  109. package/dist/internal/distribution.js +1 -0
  110. package/dist/internal/review-comments.d.ts +62 -0
  111. package/dist/internal/review-comments.d.ts.map +1 -1
  112. package/dist/internal/review-comments.js +199 -0
  113. package/dist/internal/server.d.ts.map +1 -1
  114. package/dist/internal/server.js +114 -5
  115. package/dist/playground/assets/{index-D_8I9TM0.js → index-BWqjJcRo.js} +1 -1
  116. package/dist/playground/assets/index-Bfwz2yLN.css +1 -0
  117. package/dist/playground/index.html +2 -2
  118. package/docs/reference/channels.md +15 -0
  119. package/package.json +7 -1
  120. package/src/channels/checks.ts +185 -0
  121. package/src/channels/deployments/deployments-channel.ts +109 -0
  122. package/src/channels/deployments/index.ts +26 -0
  123. package/src/channels/deployments/types.ts +80 -0
  124. package/src/channels/github/checks.ts +90 -115
  125. package/src/channels/origin/api.ts +318 -0
  126. package/src/channels/origin/binding.ts +96 -0
  127. package/src/channels/origin/checks.ts +250 -0
  128. package/src/channels/origin/index.ts +3 -0
  129. package/src/channels/origin/origin-channel.ts +18 -6
  130. package/src/channels/origin/types.ts +37 -0
  131. package/src/channels.ts +16 -0
  132. package/src/internal/authored-alias-hooks.ts +75 -7
  133. package/src/internal/authored-loaders.ts +15 -4
  134. package/src/internal/cursor-deployment-relay.ts +274 -0
  135. package/src/internal/deployment-scm-event-stream.ts +4 -4
  136. package/src/internal/distribution.ts +1 -0
  137. package/src/internal/review-comments.ts +286 -0
  138. package/src/internal/server.ts +149 -0
  139. package/dist/docs/assets/chunks/@localSearchIndexroot.DM5INS71.js +0 -1
  140. package/dist/playground/assets/index-M3nSMc6_.css +0 -1
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Pull relay for `/v0/deployment-events`.
3
+ *
4
+ * A {@link RelayAdapter} over {@link CursorRelayCore} — the same engine the
5
+ * GitHub SCM and Slack relays use, so registration heartbeats, durable
6
+ * offsets, backoff, and bounded dispatch retry are inherited rather than
7
+ * reimplemented. Only the subscribe/poll paths, the registration body, and
8
+ * the per-event filter differ.
9
+ */
10
+
11
+ import type {
12
+ DeploymentEvent,
13
+ DeploymentEventType,
14
+ DeploymentSubscription,
15
+ } from "../channels/deployments/types.js";
16
+ import { CursorRelayCore, type RelayAdapter } from "./cursor-relay-core.js";
17
+
18
+ const DEFAULT_EVENTS: readonly DeploymentEventType[] = ["deployment.succeeded"];
19
+
20
+ /** One page entry: the event fields plus its stream offset, as the sibling
21
+ * providers spell it. */
22
+ export type DeploymentRelayEvent = DeploymentEvent & { offset: string };
23
+
24
+ export interface ResolvedSubscription {
25
+ repo: string;
26
+ environments: string[];
27
+ events: Set<DeploymentEventType>;
28
+ }
29
+
30
+ interface SubscriptionStatus {
31
+ repo: string;
32
+ isSubscribed: boolean;
33
+ detail?: string;
34
+ }
35
+
36
+ export interface DeploymentEventRelayOptions {
37
+ baseUrl: string;
38
+ apiKey: string;
39
+ stateDir: string;
40
+ subscriptions: readonly ResolvedSubscription[];
41
+ dispatch: (event: DeploymentEvent) => Promise<void>;
42
+ logger: (line: string) => void;
43
+ fetchImpl?: typeof fetch;
44
+ pollWaitMs?: number;
45
+ }
46
+
47
+ /** Normalize the channel's `repos` option into concrete subscriptions. */
48
+ export function resolveDeploymentSubscriptions(options: {
49
+ repos: readonly (string | DeploymentSubscription)[];
50
+ environments?: readonly string[];
51
+ events?: readonly DeploymentEventType[];
52
+ }): ResolvedSubscription[] {
53
+ const byRepo = new Map<string, ResolvedSubscription>();
54
+ for (const entry of options.repos) {
55
+ const subscription: DeploymentSubscription =
56
+ typeof entry === "string" ? { repo: entry } : entry;
57
+ // Case-folded only: `Deployment.repository` has no documented format, and
58
+ // the server keys subscriptions the same way.
59
+ const repo = subscription.repo.trim().toLowerCase();
60
+ if (repo === "") {
61
+ throw new Error(
62
+ `deploymentsChannel: invalid repo "${subscription.repo}".`
63
+ );
64
+ }
65
+ const environments = [
66
+ ...new Set(
67
+ (subscription.environments ?? options.environments ?? [])
68
+ .map((value) => value.trim())
69
+ .filter(Boolean)
70
+ ),
71
+ ];
72
+ const events = new Set(
73
+ subscription.events ?? options.events ?? DEFAULT_EVENTS
74
+ );
75
+ if (events.size === 0) {
76
+ throw new Error(
77
+ `deploymentsChannel: repo "${repo}" subscribes to no events.`
78
+ );
79
+ }
80
+ // Later entries for the same repo widen the earlier one rather than
81
+ // silently replacing it.
82
+ const existing = byRepo.get(repo);
83
+ if (existing === undefined) {
84
+ byRepo.set(repo, { repo, environments, events });
85
+ continue;
86
+ }
87
+ for (const event of events) existing.events.add(event);
88
+ existing.environments =
89
+ existing.environments.length === 0 || environments.length === 0
90
+ ? []
91
+ : [...new Set([...existing.environments, ...environments])];
92
+ }
93
+ // Empty is legal here and means "this channel wants nothing", which is how
94
+ // an env-configured channel stays inert without a repo list. The wire-level
95
+ // empty list means deregister, so the host simply never registers it.
96
+ return [...byRepo.values()];
97
+ }
98
+
99
+ /** Whether one confirmed subscription wants this event. */
100
+ export function deploymentEventMatches(
101
+ subscription: ResolvedSubscription,
102
+ envelope: DeploymentEvent
103
+ ): boolean {
104
+ if (!subscription.events.has(envelope.eventType)) {
105
+ return false;
106
+ }
107
+ return (
108
+ subscription.environments.length === 0 ||
109
+ subscription.environments.includes(envelope.environment)
110
+ );
111
+ }
112
+
113
+ /** One channel the host can deliver a deployment event to. */
114
+ export interface DeploymentDispatchTarget {
115
+ /** Display name for logs, e.g. `executor/deployments`. */
116
+ name: string;
117
+ subscriptions: readonly ResolvedSubscription[];
118
+ deliver: (event: DeploymentEvent) => Promise<unknown>;
119
+ }
120
+
121
+ /**
122
+ * Deliver one event to every channel subscribed to it.
123
+ *
124
+ * Each channel is delivered independently: one channel's handler throwing
125
+ * must not skip the channels after it, because the relay core retries the
126
+ * whole dispatch and the channels that already ran would see the event twice.
127
+ * Throws only when every subscribed channel failed, so a retry cannot
128
+ * re-deliver to a channel that succeeded.
129
+ */
130
+ export async function fanOutDeploymentEvent(input: {
131
+ targets: readonly DeploymentDispatchTarget[];
132
+ event: DeploymentEvent;
133
+ logger: (line: string) => void;
134
+ }): Promise<void> {
135
+ const { event, logger } = input;
136
+ const matched = input.targets.filter((target) =>
137
+ target.subscriptions.some(
138
+ (subscription) =>
139
+ subscription.repo === event.repo &&
140
+ deploymentEventMatches(subscription, event)
141
+ )
142
+ );
143
+ const failures: string[] = [];
144
+ for (const target of matched) {
145
+ try {
146
+ await target.deliver(event);
147
+ } catch (error) {
148
+ failures.push(target.name);
149
+ logger(
150
+ `[agent-sdk] deployment-events: ${target.name} failed on ${event.eventId}: ${
151
+ error instanceof Error ? error.message : String(error)
152
+ }`
153
+ );
154
+ }
155
+ }
156
+ logger(
157
+ `[agent-sdk] deployment-events: ${event.eventType} ${event.repo}@${event.sha} (${event.environment}) → ${matched.length - failures.length}/${matched.length} channel(s)`
158
+ );
159
+ if (failures.length > 0 && failures.length === matched.length) {
160
+ throw new Error(
161
+ `deployment-events: every subscribed channel failed on ${event.eventId} (${failures.join(", ")})`
162
+ );
163
+ }
164
+ }
165
+
166
+ export class DeploymentEventRelay {
167
+ private readonly core: CursorRelayCore<DeploymentRelayEvent>;
168
+ private subscribed = new Map<string, ResolvedSubscription>();
169
+ private readonly loggedSkippedRepos = new Set<string>();
170
+
171
+ constructor(private readonly options: DeploymentEventRelayOptions) {
172
+ const adapter: RelayAdapter<DeploymentRelayEvent> = {
173
+ logPrefix: "deployment-events",
174
+ subscribePath: "/v0/deployment-events/subscriptions",
175
+ pollPath: "/v0/deployment-events",
176
+ buildRegistrationBody: (consumerId) => ({
177
+ consumerId,
178
+ subscriptions: options.subscriptions.map((subscription) => ({
179
+ repo: subscription.repo,
180
+ environments: subscription.environments,
181
+ events: [...subscription.events],
182
+ })),
183
+ }),
184
+ // Empty subscriptions drop this consumer's registration on clean stop
185
+ // instead of leaving it to lapse by TTL.
186
+ buildDeregistrationBody: (consumerId) => ({
187
+ consumerId,
188
+ subscriptions: [],
189
+ }),
190
+ applyRegistrationResponse: (json) => {
191
+ const result = json as { repos?: SubscriptionStatus[] };
192
+ this.subscribed = new Map();
193
+ this.loggedSkippedRepos.clear();
194
+ const lines: string[] = [];
195
+ const confirmed: string[] = [];
196
+ for (const status of result.repos ?? []) {
197
+ const wanted = options.subscriptions.find(
198
+ (subscription) => subscription.repo === status.repo
199
+ );
200
+ if (!status.isSubscribed || wanted === undefined) {
201
+ lines.push(
202
+ `not subscribed to ${status.repo}: ${status.detail ?? "unknown reason"}`
203
+ );
204
+ continue;
205
+ }
206
+ this.subscribed.set(status.repo, wanted);
207
+ confirmed.push(status.repo);
208
+ }
209
+ if (confirmed.length > 0) {
210
+ const kinds = [
211
+ ...new Set(options.subscriptions.flatMap((s) => [...s.events])),
212
+ ];
213
+ lines.push(
214
+ `subscribed to ${confirmed.join(", ")} for events: ${kinds.join(", ")}`
215
+ );
216
+ }
217
+ return lines;
218
+ },
219
+ shouldDispatch: (event) => this.shouldDispatch(event),
220
+ dispatch: ({ offset: _offset, ...envelope }) =>
221
+ options.dispatch(envelope),
222
+ };
223
+ this.core = new CursorRelayCore<DeploymentRelayEvent>({
224
+ baseUrl: options.baseUrl,
225
+ apiKey: options.apiKey,
226
+ stateDir: options.stateDir,
227
+ adapter,
228
+ logger: options.logger,
229
+ ...(options.fetchImpl === undefined
230
+ ? {}
231
+ : { fetchImpl: options.fetchImpl }),
232
+ ...(options.pollWaitMs === undefined
233
+ ? {}
234
+ : { pollWaitMs: options.pollWaitMs }),
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Whether an event from this principal's stream belongs to THIS relay.
240
+ * The stream is per-principal, and one API key can back several relays
241
+ * (other hosts, or an agent running its own alongside the host's), so each
242
+ * one re-checks the events against the subscriptions the server confirmed
243
+ * for it.
244
+ */
245
+ private shouldDispatch(event: DeploymentRelayEvent): boolean {
246
+ const envelope = event;
247
+ if (
248
+ envelope === null ||
249
+ typeof envelope !== "object" ||
250
+ typeof envelope.repo !== "string"
251
+ ) {
252
+ return false;
253
+ }
254
+ const subscription = this.subscribed.get(envelope.repo);
255
+ if (subscription === undefined) {
256
+ if (!this.loggedSkippedRepos.has(envelope.repo)) {
257
+ this.loggedSkippedRepos.add(envelope.repo);
258
+ this.options.logger(
259
+ `[agent-sdk] deployment-events: skipping events for ${envelope.repo}: not in this host's subscription (another consumer of this API key likely subscribed it)`
260
+ );
261
+ }
262
+ return false;
263
+ }
264
+ return deploymentEventMatches(subscription, envelope);
265
+ }
266
+
267
+ start(): void {
268
+ this.core.start();
269
+ }
270
+
271
+ async stop(): Promise<void> {
272
+ await this.core.stop();
273
+ }
274
+ }
@@ -34,10 +34,10 @@
34
34
  * only.
35
35
  */
36
36
 
37
- // Node builtins only: hosted engines install the consuming agent's directory
38
- // alone and load this file as raw TypeScript source, so third-party imports
39
- // (even zod) do not resolve from here. See the tenant-mcp internals for the
40
- // same constraint.
37
+ // Hosted engines install the consuming agent's directory alone and load this
38
+ // file as raw TypeScript source. Relative and `node:` imports resolve here;
39
+ // this package's own npm dependencies retry against the running `@cursor/july`
40
+ // install (authored-alias-hooks). Do not add deps that package does not have.
41
41
  import { createHmac } from "node:crypto";
42
42
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
43
43
  import { join } from "node:path";
@@ -97,6 +97,7 @@ export const AUTHORING_ENTRY_FILES: ReadonlyArray<
97
97
  ["channels/slack", "channels/slack/index.js"],
98
98
  ["channels/github", "channels/github/index.js"],
99
99
  ["channels/origin", "channels/origin/index.js"],
100
+ ["channels/deployments", "channels/deployments/index.js"],
100
101
  ["skills", "skills.js"],
101
102
  ["schedules", "schedules.js"],
102
103
  ["reminders", "reminders.js"],
@@ -110,6 +110,43 @@ export type ReviewCommentReaderImplementation = {
110
110
  ) => Promise<ExistingReviewComment[]>;
111
111
  };
112
112
 
113
+ export type ResolveReviewThreadsArgs = {
114
+ target: ScmReviewTarget;
115
+ /** REST comment ids whose threads should be resolved. */
116
+ commentIds: readonly number[];
117
+ signal?: AbortSignal;
118
+ };
119
+
120
+ /**
121
+ * Outcome of resolving review threads for a set of comment ids. Callers
122
+ * treat this as best-effort actuation: a failed resolve must not fail the
123
+ * review that already posted.
124
+ */
125
+ export type ResolveReviewThreadsResult = {
126
+ requested: number;
127
+ resolved: number;
128
+ alreadyResolved: number;
129
+ notFound: number;
130
+ failed: number;
131
+ detail: string;
132
+ };
133
+
134
+ /**
135
+ * Write side of thread resolution, the counterpart of
136
+ * {@link ReviewCommentReaderImplementation}. Bugbot's equivalent is
137
+ * `IGitProvider.resolveReviewThreadForComment` / `resolveThreads`.
138
+ *
139
+ * GitHub only exposes resolve via GraphQL (`resolveReviewThread`). REST
140
+ * has no equivalent. The mutation runs as whatever identity minted the
141
+ * bound GitHub client — for `githubChannel({ cursorAccount })` that is
142
+ * the Cursor installation, the same identity that posted the comments.
143
+ */
144
+ export type ReviewThreadResolverImplementation = {
145
+ resolveReviewThreadsForComments: (
146
+ args: ResolveReviewThreadsArgs
147
+ ) => Promise<ResolveReviewThreadsResult>;
148
+ };
149
+
113
150
  /** Parse a PR URL into the SCM target to read comments from. */
114
151
  export function resolveScmReviewTarget(
115
152
  prUrl: string
@@ -141,9 +178,65 @@ export function createReviewCommentReader(args: {
141
178
  }
142
179
  }
143
180
 
181
+ /**
182
+ * Resolver for a resolved target. Returns undefined when the host cannot
183
+ * serve that provider, so callers degrade instead of throwing.
184
+ */
185
+ export function createReviewThreadResolver(args: {
186
+ target: ScmReviewTarget;
187
+ github?: HostGitHubClient;
188
+ }): ReviewThreadResolverImplementation | undefined {
189
+ switch (args.target.provider) {
190
+ case "github":
191
+ return args.github === undefined
192
+ ? undefined
193
+ : createHostGithubReviewThreadResolver(args.github);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Resolve the review threads that own `commentIds` on `prUrl`.
199
+ *
200
+ * No-ops (never throws) when the PR is on an SCM this host cannot write,
201
+ * no client is bound, or `commentIds` is empty. Same degrade-don't-fail
202
+ * posture as {@link createReviewCommentReader}.
203
+ */
204
+ export async function resolveReviewThreadsForComments(args: {
205
+ prUrl: string;
206
+ commentIds: readonly number[];
207
+ github?: HostGitHubClient;
208
+ resolver?: ReviewThreadResolverImplementation;
209
+ signal?: AbortSignal;
210
+ }): Promise<ResolveReviewThreadsResult> {
211
+ if (args.commentIds.length === 0) {
212
+ return skippedResolve("no comments to resolve");
213
+ }
214
+ const target = resolveScmReviewTarget(args.prUrl);
215
+ if (target === undefined) {
216
+ return skippedResolve("no SCM resolver for this PR URL");
217
+ }
218
+ const resolver =
219
+ args.resolver ??
220
+ createReviewThreadResolver({
221
+ target,
222
+ github: args.github,
223
+ });
224
+ if (resolver === undefined) {
225
+ return skippedResolve("no GitHub client bound to resolve threads");
226
+ }
227
+ return resolver.resolveReviewThreadsForComments({
228
+ target,
229
+ commentIds: args.commentIds,
230
+ signal: args.signal,
231
+ });
232
+ }
233
+
144
234
  /** Cap on pages pulled per PR; GitHub serves 100 comments per page. */
145
235
  const REVIEW_COMMENTS_MAX_PAGES = 5;
146
236
  const REVIEW_COMMENTS_PAGE_SIZE = 100;
237
+ /** Cap on review-thread pages when mapping comment ids to GraphQL thread ids. */
238
+ const REVIEW_THREADS_MAX_PAGES = 20;
239
+ const REVIEW_THREADS_PAGE_SIZE = 100;
147
240
 
148
241
  /**
149
242
  * Reads inline review comments through the host GitHub client (Cursor
@@ -182,6 +275,188 @@ export function createHostGithubReviewCommentReader(
182
275
  };
183
276
  }
184
277
 
278
+ type GithubReviewThreadNode = {
279
+ id?: string;
280
+ isResolved?: boolean;
281
+ comments?: {
282
+ nodes?: Array<{ databaseId?: number | null } | null>;
283
+ };
284
+ };
285
+
286
+ type GithubReviewThreadsQuery = {
287
+ repository?: {
288
+ pullRequest?: {
289
+ reviewThreads?: {
290
+ nodes?: Array<GithubReviewThreadNode | null>;
291
+ pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
292
+ };
293
+ } | null;
294
+ } | null;
295
+ };
296
+
297
+ type GithubResolveThreadMutation = {
298
+ resolveReviewThread?: {
299
+ thread?: { isResolved?: boolean };
300
+ };
301
+ };
302
+
303
+ /**
304
+ * Resolves inline review threads through the host GitHub client (Cursor
305
+ * account lease, App installation token, or PAT). GitHub GraphQL has no
306
+ * comment→thread field, so this pages the PR's review threads and matches
307
+ * each REST comment id by `databaseId` — the same approach as Bugbot's
308
+ * `resolveReviewThreadForComment`.
309
+ */
310
+ export function createHostGithubReviewThreadResolver(
311
+ github: HostGitHubClient
312
+ ): ReviewThreadResolverImplementation {
313
+ return {
314
+ async resolveReviewThreadsForComments({ target, commentIds, signal }) {
315
+ const uniqueIds = [...new Set(commentIds.filter((id) => id > 0))];
316
+ if (uniqueIds.length === 0) {
317
+ return skippedResolve("no comments to resolve");
318
+ }
319
+ const [owner, repo] = target.repo.split("/", 2);
320
+ if (owner === undefined || repo === undefined || repo === "") {
321
+ return skippedResolve("unparseable owner/repo from PR URL");
322
+ }
323
+
324
+ try {
325
+ const octokit = await github.getOctokit();
326
+ const wanted = new Set(uniqueIds);
327
+ const matched = new Map<
328
+ number,
329
+ { threadId: string; isResolved: boolean }
330
+ >();
331
+
332
+ let cursor: string | null = null;
333
+ for (let page = 0; page < REVIEW_THREADS_MAX_PAGES; page++) {
334
+ signal?.throwIfAborted();
335
+ const response = (await octokit.graphql(REVIEW_THREADS_FOR_PR_QUERY, {
336
+ owner,
337
+ repo,
338
+ number: target.prNumber,
339
+ cursor,
340
+ })) as GithubReviewThreadsQuery;
341
+ const reviewThreads = response.repository?.pullRequest?.reviewThreads;
342
+ for (const thread of reviewThreads?.nodes ?? []) {
343
+ if (thread?.id === undefined || thread.id === "") {
344
+ continue;
345
+ }
346
+ for (const comment of thread.comments?.nodes ?? []) {
347
+ const databaseId = comment?.databaseId;
348
+ if (
349
+ databaseId !== undefined &&
350
+ databaseId !== null &&
351
+ wanted.has(databaseId) &&
352
+ !matched.has(databaseId)
353
+ ) {
354
+ matched.set(databaseId, {
355
+ threadId: thread.id,
356
+ isResolved: thread.isResolved === true,
357
+ });
358
+ }
359
+ }
360
+ }
361
+ if (matched.size === wanted.size) {
362
+ break;
363
+ }
364
+ const pageInfo = reviewThreads?.pageInfo;
365
+ if (pageInfo?.hasNextPage !== true || !pageInfo.endCursor) {
366
+ break;
367
+ }
368
+ cursor = pageInfo.endCursor;
369
+ }
370
+
371
+ const toResolve = [
372
+ ...new Map(
373
+ [...matched.values()]
374
+ .filter((thread) => !thread.isResolved)
375
+ .map((thread) => [thread.threadId, thread.threadId])
376
+ ).values(),
377
+ ];
378
+ let resolved = 0;
379
+ let failed = 0;
380
+ for (const threadId of toResolve) {
381
+ signal?.throwIfAborted();
382
+ try {
383
+ const mutation = (await octokit.graphql(
384
+ RESOLVE_REVIEW_THREAD_MUTATION,
385
+ { threadId }
386
+ )) as GithubResolveThreadMutation;
387
+ if (mutation.resolveReviewThread?.thread?.isResolved === true) {
388
+ resolved += 1;
389
+ } else {
390
+ failed += 1;
391
+ }
392
+ } catch {
393
+ failed += 1;
394
+ }
395
+ }
396
+
397
+ const alreadyResolved = new Set(
398
+ [...matched.values()]
399
+ .filter((thread) => thread.isResolved)
400
+ .map((thread) => thread.threadId)
401
+ ).size;
402
+ const notFound = wanted.size - matched.size;
403
+ return {
404
+ requested: uniqueIds.length,
405
+ resolved,
406
+ alreadyResolved,
407
+ notFound,
408
+ failed,
409
+ detail: `resolved ${resolved} of ${toResolve.length} open thread(s) for ${uniqueIds.length} comment(s) via host GitHub (${alreadyResolved} already resolved, ${notFound} not found, ${failed} failed)`,
410
+ };
411
+ } catch (error) {
412
+ if (signal?.aborted) {
413
+ throw error;
414
+ }
415
+ return {
416
+ requested: uniqueIds.length,
417
+ resolved: 0,
418
+ alreadyResolved: 0,
419
+ notFound: 0,
420
+ failed: uniqueIds.length,
421
+ detail: `host GitHub thread resolve failed: ${error instanceof Error ? error.message : String(error)}`,
422
+ };
423
+ }
424
+ },
425
+ };
426
+ }
427
+
428
+ const REVIEW_THREADS_FOR_PR_QUERY = `
429
+ query ReviewThreadsForPr(
430
+ $owner: String!
431
+ $repo: String!
432
+ $number: Int!
433
+ $cursor: String
434
+ ) {
435
+ repository(owner: $owner, name: $repo) {
436
+ pullRequest(number: $number) {
437
+ reviewThreads(first: ${REVIEW_THREADS_PAGE_SIZE}, after: $cursor) {
438
+ nodes {
439
+ id
440
+ isResolved
441
+ comments(first: ${REVIEW_THREADS_PAGE_SIZE}) {
442
+ nodes { databaseId }
443
+ }
444
+ }
445
+ pageInfo { hasNextPage endCursor }
446
+ }
447
+ }
448
+ }
449
+ }
450
+ `;
451
+
452
+ const RESOLVE_REVIEW_THREAD_MUTATION = `
453
+ mutation ResolveReviewThread($threadId: ID!) {
454
+ resolveReviewThread(input: { threadId: $threadId }) {
455
+ thread { isResolved }
456
+ }
457
+ }
458
+ `;
459
+
185
460
  type GithubReviewCommentLike = {
186
461
  id: number;
187
462
  path: string;
@@ -504,6 +779,17 @@ function skippedHost(detail: string): ReviewCommentPostResult {
504
779
  };
505
780
  }
506
781
 
782
+ function skippedResolve(detail: string): ResolveReviewThreadsResult {
783
+ return {
784
+ requested: 0,
785
+ resolved: 0,
786
+ alreadyResolved: 0,
787
+ notFound: 0,
788
+ failed: 0,
789
+ detail,
790
+ };
791
+ }
792
+
507
793
  function parseGithubPrUrl(
508
794
  prUrl: string
509
795
  ):