@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.
- package/dist/channels/checks.d.ts +111 -0
- package/dist/channels/checks.d.ts.map +1 -0
- package/dist/channels/checks.js +86 -0
- package/dist/channels/deployments/deployments-channel.d.ts +44 -0
- package/dist/channels/deployments/deployments-channel.d.ts.map +1 -0
- package/dist/channels/deployments/deployments-channel.js +92 -0
- package/dist/channels/deployments/index.d.ts +26 -0
- package/dist/channels/deployments/index.d.ts.map +1 -0
- package/dist/channels/deployments/index.js +25 -0
- package/dist/channels/deployments/types.d.ts +69 -0
- package/dist/channels/deployments/types.d.ts.map +1 -0
- package/dist/channels/deployments/types.js +6 -0
- package/dist/channels/github/checks.d.ts +13 -48
- package/dist/channels/github/checks.d.ts.map +1 -1
- package/dist/channels/github/checks.js +56 -64
- package/dist/channels/origin/api.d.ts +100 -0
- package/dist/channels/origin/api.d.ts.map +1 -0
- package/dist/channels/origin/api.js +225 -0
- package/dist/channels/origin/binding.d.ts +44 -0
- package/dist/channels/origin/binding.d.ts.map +1 -0
- package/dist/channels/origin/binding.js +45 -0
- package/dist/channels/origin/checks.d.ts +107 -0
- package/dist/channels/origin/checks.d.ts.map +1 -0
- package/dist/channels/origin/checks.js +145 -0
- package/dist/channels/origin/index.d.ts +3 -0
- package/dist/channels/origin/index.d.ts.map +1 -1
- package/dist/channels/origin/index.js +3 -0
- package/dist/channels/origin/origin-channel.d.ts +4 -4
- package/dist/channels/origin/origin-channel.d.ts.map +1 -1
- package/dist/channels/origin/origin-channel.js +12 -2
- package/dist/channels/origin/types.d.ts +36 -0
- package/dist/channels/origin/types.d.ts.map +1 -1
- package/dist/channels.d.ts +1 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +1 -0
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +3 -3
- package/dist/docs/assets/{app.BETd-WqU.js → app.b8bhB2SV.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.DGZeXVqn.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.wEaGEmV9.js → VPLocalSearchBox.lLSeIyWw.js} +1 -1
- package/dist/docs/assets/chunks/{theme.DNDicFOP.js → theme.C_owesa-.js} +2 -2
- package/dist/docs/assets/{reference_channels.md.MN8iDy8V.js → reference_channels.md.G5GysZ7e.js} +2 -2
- package/dist/docs/assets/{reference_channels.md.MN8iDy8V.lean.js → reference_channels.md.G5GysZ7e.lean.js} +1 -1
- package/dist/docs/building-with-agents.html +3 -3
- package/dist/docs/concepts.html +3 -3
- package/dist/docs/deployment.html +3 -3
- package/dist/docs/evals.html +3 -3
- package/dist/docs/example-agents/approval-buddy.html +3 -3
- package/dist/docs/example-agents/benny.html +3 -3
- package/dist/docs/example-agents/bugbot.html +3 -3
- package/dist/docs/example-agents/codebase-wiki.html +3 -3
- package/dist/docs/example-agents/codeowners-review.html +3 -3
- package/dist/docs/example-agents/concierge.html +3 -3
- package/dist/docs/example-agents/fsd.html +3 -3
- package/dist/docs/example-agents/index.html +3 -3
- package/dist/docs/example-agents/knowledge-base.html +3 -3
- package/dist/docs/example-agents/oncall.html +3 -3
- package/dist/docs/example-agents/security-reviewer.html +3 -3
- package/dist/docs/example-agents/slack-agent.html +3 -3
- package/dist/docs/example-agents/weather-agent.html +3 -3
- package/dist/docs/guides/agent-to-agent.html +3 -3
- package/dist/docs/guides/cloud-runtime.html +3 -3
- package/dist/docs/guides/convert-automation.html +3 -3
- package/dist/docs/guides/github.html +3 -3
- package/dist/docs/guides/human-in-the-loop.html +3 -3
- package/dist/docs/guides/mcp-oauth.html +3 -3
- package/dist/docs/guides/opentelemetry.html +3 -3
- package/dist/docs/guides/slack.html +3 -3
- package/dist/docs/guides/webhooks.html +3 -3
- package/dist/docs/hashmap.json +1 -1
- package/dist/docs/hillclimbing.html +3 -3
- package/dist/docs/index.html +3 -3
- package/dist/docs/quickstart.html +3 -3
- package/dist/docs/reference/agent-config.html +3 -3
- package/dist/docs/reference/artifacts.html +3 -3
- package/dist/docs/reference/channels.html +5 -5
- package/dist/docs/reference/cli.html +3 -3
- package/dist/docs/reference/connections.html +3 -3
- package/dist/docs/reference/hooks.html +3 -3
- package/dist/docs/reference/http-api.html +3 -3
- package/dist/docs/reference/instructions.html +3 -3
- package/dist/docs/reference/playground.html +3 -3
- package/dist/docs/reference/project-layout.html +3 -3
- package/dist/docs/reference/prompt.html +3 -3
- package/dist/docs/reference/schedules.html +3 -3
- package/dist/docs/reference/sessions.html +3 -3
- package/dist/docs/reference/skills.html +3 -3
- package/dist/docs/reference/subagents.html +3 -3
- package/dist/docs/reference/tools.html +3 -3
- package/dist/docs/scaffolding-agents.html +3 -3
- package/dist/docs/storage.html +3 -3
- package/dist/docs/templates/agentic-owners.html +3 -3
- package/dist/docs/templates/demo.html +3 -3
- package/dist/docs/templates/pr-autofixer.html +3 -3
- package/dist/docs/templates/security-reviewer.html +3 -3
- package/dist/docs/templates/triage.html +3 -3
- package/dist/docs/troubleshooting.html +3 -3
- package/dist/internal/authored-alias-hooks.d.ts +12 -0
- package/dist/internal/authored-alias-hooks.d.ts.map +1 -1
- package/dist/internal/authored-alias-hooks.js +60 -8
- package/dist/internal/authored-loaders.d.ts +5 -3
- package/dist/internal/authored-loaders.d.ts.map +1 -1
- package/dist/internal/authored-loaders.js +13 -4
- package/dist/internal/cursor-deployment-relay.d.ts +77 -0
- package/dist/internal/cursor-deployment-relay.d.ts.map +1 -0
- package/dist/internal/cursor-deployment-relay.js +201 -0
- package/dist/internal/deployment-scm-event-stream.js +4 -4
- package/dist/internal/distribution.d.ts.map +1 -1
- package/dist/internal/distribution.js +1 -0
- package/dist/internal/review-comments.d.ts +62 -0
- package/dist/internal/review-comments.d.ts.map +1 -1
- package/dist/internal/review-comments.js +199 -0
- package/dist/internal/server.d.ts.map +1 -1
- package/dist/internal/server.js +114 -5
- package/dist/playground/assets/{index-D_8I9TM0.js → index-BWqjJcRo.js} +1 -1
- package/dist/playground/assets/index-Bfwz2yLN.css +1 -0
- package/dist/playground/index.html +2 -2
- package/docs/reference/channels.md +15 -0
- package/package.json +7 -1
- package/src/channels/checks.ts +185 -0
- package/src/channels/deployments/deployments-channel.ts +109 -0
- package/src/channels/deployments/index.ts +26 -0
- package/src/channels/deployments/types.ts +80 -0
- package/src/channels/github/checks.ts +90 -115
- package/src/channels/origin/api.ts +318 -0
- package/src/channels/origin/binding.ts +96 -0
- package/src/channels/origin/checks.ts +250 -0
- package/src/channels/origin/index.ts +3 -0
- package/src/channels/origin/origin-channel.ts +18 -6
- package/src/channels/origin/types.ts +37 -0
- package/src/channels.ts +16 -0
- package/src/internal/authored-alias-hooks.ts +75 -7
- package/src/internal/authored-loaders.ts +15 -4
- package/src/internal/cursor-deployment-relay.ts +274 -0
- package/src/internal/deployment-scm-event-stream.ts +4 -4
- package/src/internal/distribution.ts +1 -0
- package/src/internal/review-comments.ts +286 -0
- package/src/internal/server.ts +149 -0
- package/dist/docs/assets/chunks/@localSearchIndexroot.DM5INS71.js +0 -1
- 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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
):
|