@agrentingai/paperclip-adapter 0.2.0
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/README.md +306 -0
- package/dist/server/index.cjs +1406 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +895 -0
- package/dist/server/index.d.ts +895 -0
- package/dist/server/index.js +1336 -0
- package/dist/server/index.js.map +1 -0
- package/dist/ui/index.cjs +125 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +36 -0
- package/dist/ui/index.d.ts +36 -0
- package/dist/ui/index.js +98 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +65 -0
- package/server/src/adapter.test.ts +497 -0
- package/server/src/adapter.ts +1044 -0
- package/server/src/balance-monitor.test.ts +147 -0
- package/server/src/balance-monitor.ts +118 -0
- package/server/src/client.test.ts +949 -0
- package/server/src/client.ts +550 -0
- package/server/src/comment-sync.test.ts +25 -0
- package/server/src/comment-sync.ts +71 -0
- package/server/src/crypto.test.ts +62 -0
- package/server/src/crypto.ts +25 -0
- package/server/src/index.ts +67 -0
- package/server/src/polling.test.ts +208 -0
- package/server/src/polling.ts +183 -0
- package/server/src/types.ts +244 -0
- package/server/src/webhook-handler.test.ts +379 -0
- package/server/src/webhook-handler.ts +292 -0
- package/ui/src/adapter.ts +131 -0
- package/ui/src/index.ts +2 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agrenting adapter configuration schema.
|
|
3
|
+
* These fields are rendered in the Paperclip UI when configuring an Agrenting agent.
|
|
4
|
+
*/
|
|
5
|
+
export interface AgrentingAdapterConfig {
|
|
6
|
+
/** Agrenting platform URL, e.g. https://www.agrenting.com */
|
|
7
|
+
agrentingUrl: string;
|
|
8
|
+
/** API key for Agrenting authentication */
|
|
9
|
+
apiKey: string;
|
|
10
|
+
/** Decentralized identifier of the target agent, e.g. did:agrenting:my-agent */
|
|
11
|
+
agentDid: string;
|
|
12
|
+
/** Webhook secret for receiving task completion callbacks */
|
|
13
|
+
webhookSecret?: string;
|
|
14
|
+
/** URL where Agrenting should POST task events (overrides built-in listener) */
|
|
15
|
+
webhookCallbackUrl?: string;
|
|
16
|
+
/** Pricing model for the agent: fixed, per-token, or subscription */
|
|
17
|
+
pricingModel?: "fixed" | "per-token" | "subscription";
|
|
18
|
+
/** Task timeout in seconds (default: 600) */
|
|
19
|
+
timeoutSec?: number;
|
|
20
|
+
/** How instructions are handled: "managed" (uploaded to Agrenting) or "inline" (passed in task context) */
|
|
21
|
+
instructionsBundleMode?: "managed" | "inline";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Result of executing a task via the Agrenting adapter */
|
|
25
|
+
export interface AgrentingExecutionResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
output?: string;
|
|
28
|
+
error?: string;
|
|
29
|
+
taskId?: string;
|
|
30
|
+
durationMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Task status as returned by the Agrenting API */
|
|
34
|
+
export type AgrentingTaskStatus =
|
|
35
|
+
| "pending"
|
|
36
|
+
| "in_progress"
|
|
37
|
+
| "completed"
|
|
38
|
+
| "failed"
|
|
39
|
+
| "cancelled";
|
|
40
|
+
|
|
41
|
+
/** Task payload from Agrenting API */
|
|
42
|
+
export interface AgrentingTask {
|
|
43
|
+
id: string;
|
|
44
|
+
status: AgrentingTaskStatus;
|
|
45
|
+
client_agent_id: string;
|
|
46
|
+
provider_agent_id: string;
|
|
47
|
+
capability: string;
|
|
48
|
+
input: string;
|
|
49
|
+
output?: string;
|
|
50
|
+
error_reason?: string;
|
|
51
|
+
progress_percent?: number;
|
|
52
|
+
progress_message?: string;
|
|
53
|
+
created_at: string;
|
|
54
|
+
updated_at: string;
|
|
55
|
+
completed_at?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Marketplace agent info returned by discover endpoint */
|
|
59
|
+
export interface AgentInfo {
|
|
60
|
+
id: string;
|
|
61
|
+
did: string;
|
|
62
|
+
name: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
capabilities: string[];
|
|
65
|
+
price_per_task?: string;
|
|
66
|
+
min_price?: string;
|
|
67
|
+
max_price?: string;
|
|
68
|
+
reputation?: number;
|
|
69
|
+
total_tasks?: number;
|
|
70
|
+
success_rate?: number;
|
|
71
|
+
avatar_url?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Platform balance from ledger — available, escrowed, and total amounts */
|
|
75
|
+
export interface BalanceInfo {
|
|
76
|
+
available: string;
|
|
77
|
+
escrow: string;
|
|
78
|
+
total: string;
|
|
79
|
+
currency?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Payment info for a task — escrow lock and transaction details */
|
|
83
|
+
export interface PaymentInfo {
|
|
84
|
+
id: string;
|
|
85
|
+
task_id: string;
|
|
86
|
+
amount: string;
|
|
87
|
+
currency: string;
|
|
88
|
+
status: string;
|
|
89
|
+
payment_type?: string;
|
|
90
|
+
created_at: string;
|
|
91
|
+
transaction_hash?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Ledger transaction record */
|
|
95
|
+
export interface TransactionInfo {
|
|
96
|
+
id: string;
|
|
97
|
+
type: string;
|
|
98
|
+
amount: string;
|
|
99
|
+
currency: string;
|
|
100
|
+
status: string;
|
|
101
|
+
created_at: string;
|
|
102
|
+
task_id?: string;
|
|
103
|
+
description?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Options for marketplace agent discovery */
|
|
107
|
+
export interface DiscoverAgentsOptions {
|
|
108
|
+
capability?: string;
|
|
109
|
+
minPrice?: number;
|
|
110
|
+
maxPrice?: number;
|
|
111
|
+
minReputation?: number;
|
|
112
|
+
sortBy?: string;
|
|
113
|
+
limit?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Options for creating a task payment to lock escrow funds */
|
|
117
|
+
export interface CreateTaskPaymentOptions {
|
|
118
|
+
cryptoCurrency?: string;
|
|
119
|
+
paymentType?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Full agent profile returned by GET /api/v1/agents/:did */
|
|
123
|
+
export interface AgentProfile {
|
|
124
|
+
id: string;
|
|
125
|
+
did: string;
|
|
126
|
+
name: string;
|
|
127
|
+
description?: string;
|
|
128
|
+
capabilities: string[];
|
|
129
|
+
pricing_tiers: Array<{
|
|
130
|
+
model: string;
|
|
131
|
+
price_per_task?: string;
|
|
132
|
+
price_per_token?: string;
|
|
133
|
+
monthly_price?: string;
|
|
134
|
+
}>;
|
|
135
|
+
pricing_model?: "fixed" | "per-token" | "subscription";
|
|
136
|
+
base_price?: string;
|
|
137
|
+
reviews?: {
|
|
138
|
+
average_rating: number;
|
|
139
|
+
total_reviews: number;
|
|
140
|
+
};
|
|
141
|
+
reputation_score?: number;
|
|
142
|
+
total_earnings?: string;
|
|
143
|
+
verified?: boolean;
|
|
144
|
+
response_time_avg?: number;
|
|
145
|
+
availability_status: "available" | "busy" | "offline";
|
|
146
|
+
success_rate?: number;
|
|
147
|
+
total_tasks_completed?: number;
|
|
148
|
+
metadata?: Record<string, unknown>;
|
|
149
|
+
avatar_url?: string;
|
|
150
|
+
created_at: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Result of hiring an agent via POST /api/v1/agents/:did/hire */
|
|
154
|
+
export interface HireAgentResult {
|
|
155
|
+
agent_did: string;
|
|
156
|
+
adapter_config: {
|
|
157
|
+
agrentingUrl: string;
|
|
158
|
+
agentDid: string;
|
|
159
|
+
pricingModel: string;
|
|
160
|
+
webhookSecret?: string;
|
|
161
|
+
};
|
|
162
|
+
status: "hired" | "pending_approval";
|
|
163
|
+
hired_at: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Hiring record returned by POST /api/v1/agents/:did/hire */
|
|
167
|
+
export interface Hiring {
|
|
168
|
+
id: string;
|
|
169
|
+
agent_id: string;
|
|
170
|
+
agent_did: string;
|
|
171
|
+
client_agent_id: string;
|
|
172
|
+
status: string;
|
|
173
|
+
pricing_model?: string;
|
|
174
|
+
created_at: string;
|
|
175
|
+
updated_at: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Options for sending a message to a task */
|
|
179
|
+
export interface SendMessageOptions {
|
|
180
|
+
message: string;
|
|
181
|
+
messageType?: "instruction" | "feedback" | "question";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Result of sending a message to a task */
|
|
185
|
+
export interface SendMessageResult {
|
|
186
|
+
message_id: string;
|
|
187
|
+
task_id: string;
|
|
188
|
+
sent_at: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Task message for bidirectional communication */
|
|
192
|
+
export interface TaskMessage {
|
|
193
|
+
id: string;
|
|
194
|
+
task_id: string;
|
|
195
|
+
content: string;
|
|
196
|
+
message_type: "instruction" | "feedback" | "question";
|
|
197
|
+
sender_agent_did?: string;
|
|
198
|
+
sender_user_id?: string;
|
|
199
|
+
sender_name?: string;
|
|
200
|
+
created_at: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Result of reassigning a task to a different agent */
|
|
204
|
+
export interface ReassignTaskResult {
|
|
205
|
+
task_id: string;
|
|
206
|
+
previous_agent_did: string;
|
|
207
|
+
new_agent_did: string;
|
|
208
|
+
new_provider_agent_id?: string;
|
|
209
|
+
status?: string;
|
|
210
|
+
reassigned_at?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Hiring message for communication with hired agent */
|
|
214
|
+
export interface HiringMessage {
|
|
215
|
+
id: string;
|
|
216
|
+
hiring_id: string;
|
|
217
|
+
sender_agent_id: string;
|
|
218
|
+
content: string;
|
|
219
|
+
created_at: string;
|
|
220
|
+
sender_name?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Capability returned by GET /api/v1/capabilities */
|
|
224
|
+
export interface Capability {
|
|
225
|
+
name: string;
|
|
226
|
+
description?: string;
|
|
227
|
+
category?: string;
|
|
228
|
+
agent_count?: number;
|
|
229
|
+
avg_price?: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Options for auto-selecting an agent */
|
|
233
|
+
export interface AutoSelectOptions {
|
|
234
|
+
capability: string;
|
|
235
|
+
maxPrice?: string;
|
|
236
|
+
minReputation?: number;
|
|
237
|
+
sortBy?: "reputation_score" | "base_price" | "availability";
|
|
238
|
+
preferAvailable?: boolean;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Options for retrying a hiring */
|
|
242
|
+
export interface RetryHiringOptions {
|
|
243
|
+
reason?: string;
|
|
244
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createWebhookHandler,
|
|
4
|
+
registerTaskMapping,
|
|
5
|
+
unregisterTaskMapping,
|
|
6
|
+
getActiveTaskMappings,
|
|
7
|
+
stopRegistryCleanup,
|
|
8
|
+
} from "./webhook-handler.js";
|
|
9
|
+
import type { PaperclipApiClient } from "./webhook-handler.js";
|
|
10
|
+
import type { AgrentingAdapterConfig } from "./types.js";
|
|
11
|
+
|
|
12
|
+
// We need to mock the crypto module so verifyWebhookSignature returns true
|
|
13
|
+
vi.mock("./crypto.js", () => ({
|
|
14
|
+
verifyWebhookSignature: vi.fn().mockResolvedValue(true),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const mockConfig: AgrentingAdapterConfig = {
|
|
18
|
+
agrentingUrl: "https://api.agrenting.com",
|
|
19
|
+
apiKey: "test-key",
|
|
20
|
+
agentDid: "did:agrenting:test",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function mockApi(): PaperclipApiClient & {
|
|
24
|
+
updateIssue: ReturnType<typeof vi.fn>;
|
|
25
|
+
postComment: ReturnType<typeof vi.fn>;
|
|
26
|
+
} {
|
|
27
|
+
return {
|
|
28
|
+
updateIssue: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
postComment: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
// Clear the task registry between tests
|
|
36
|
+
const mappings = getActiveTaskMappings();
|
|
37
|
+
for (const key of mappings.keys()) {
|
|
38
|
+
unregisterTaskMapping(key);
|
|
39
|
+
}
|
|
40
|
+
stopRegistryCleanup();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Task registry
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe("task registry", () => {
|
|
48
|
+
it("registers and retrieves a task mapping", () => {
|
|
49
|
+
registerTaskMapping("task-1", "issue-1", "company-1", mockConfig);
|
|
50
|
+
const mappings = getActiveTaskMappings();
|
|
51
|
+
expect(mappings.has("task-1")).toBe(true);
|
|
52
|
+
const entry = mappings.get("task-1")!;
|
|
53
|
+
expect(entry.issueId).toBe("issue-1");
|
|
54
|
+
expect(entry.companyId).toBe("company-1");
|
|
55
|
+
expect(entry.status).toBe("pending");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("unregisters a task mapping", () => {
|
|
59
|
+
registerTaskMapping("task-2", "issue-2", "company-1", mockConfig);
|
|
60
|
+
unregisterTaskMapping("task-2");
|
|
61
|
+
expect(getActiveTaskMappings().has("task-2")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns a ReadonlyMap", () => {
|
|
65
|
+
registerTaskMapping("task-3", "issue-3", "company-1", mockConfig);
|
|
66
|
+
const mappings = getActiveTaskMappings();
|
|
67
|
+
expect(mappings).toBeInstanceOf(Map);
|
|
68
|
+
expect(mappings.size).toBeGreaterThanOrEqual(1);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Webhook handler
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("createWebhookHandler", () => {
|
|
77
|
+
it("returns 400 for invalid JSON body", async () => {
|
|
78
|
+
const api = mockApi();
|
|
79
|
+
const handler = createWebhookHandler({
|
|
80
|
+
webhookSecret: "secret",
|
|
81
|
+
api,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await handler("not-json", {
|
|
85
|
+
"x-webhook-signature": "sig",
|
|
86
|
+
"x-webhook-event": "task.completed",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.status).toBe(400);
|
|
90
|
+
expect(result.body).toBe("Invalid JSON");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns 401 when signature is invalid", async () => {
|
|
94
|
+
const { verifyWebhookSignature } = await import("./crypto.js");
|
|
95
|
+
(verifyWebhookSignature as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
|
|
96
|
+
|
|
97
|
+
const api = mockApi();
|
|
98
|
+
const handler = createWebhookHandler({
|
|
99
|
+
webhookSecret: "secret",
|
|
100
|
+
api,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = await handler(
|
|
104
|
+
JSON.stringify({ task_id: "t1", status: "completed" }),
|
|
105
|
+
{ "x-webhook-signature": "bad-sig", "x-webhook-event": "task.completed" }
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(result.status).toBe(401);
|
|
109
|
+
expect(result.body).toBe("Invalid signature");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns 400 when task_id is missing", async () => {
|
|
113
|
+
const api = mockApi();
|
|
114
|
+
const handler = createWebhookHandler({
|
|
115
|
+
webhookSecret: "secret",
|
|
116
|
+
api,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const result = await handler(
|
|
120
|
+
JSON.stringify({ status: "completed" }),
|
|
121
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.completed" }
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(result.status).toBe(400);
|
|
125
|
+
expect(result.body).toBe("Missing task_id in payload");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns 200 with no active mapping for unknown task_id", async () => {
|
|
129
|
+
const api = mockApi();
|
|
130
|
+
const handler = createWebhookHandler({
|
|
131
|
+
webhookSecret: "secret",
|
|
132
|
+
api,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = await handler(
|
|
136
|
+
JSON.stringify({ task_id: "unknown-task", status: "completed" }),
|
|
137
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.completed" }
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result.status).toBe(200);
|
|
141
|
+
expect(result.body).toBe("OK (no active mapping)");
|
|
142
|
+
expect(api.updateIssue).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Event handling
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe("event handling", () => {
|
|
151
|
+
it("handles task.created event", async () => {
|
|
152
|
+
const api = mockApi();
|
|
153
|
+
const handler = createWebhookHandler({
|
|
154
|
+
webhookSecret: "secret",
|
|
155
|
+
api,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
registerTaskMapping("task-created", "issue-created", "c1", mockConfig);
|
|
159
|
+
|
|
160
|
+
const result = await handler(
|
|
161
|
+
JSON.stringify({ task_id: "task-created", status: "pending" }),
|
|
162
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.created" }
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(result.status).toBe(200);
|
|
166
|
+
expect(api.postComment).toHaveBeenCalledWith(
|
|
167
|
+
"issue-created",
|
|
168
|
+
expect.stringContaining("task created")
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("handles task.claimed event", async () => {
|
|
173
|
+
const api = mockApi();
|
|
174
|
+
const handler = createWebhookHandler({
|
|
175
|
+
webhookSecret: "secret",
|
|
176
|
+
api,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
registerTaskMapping("task-claimed", "issue-claimed", "c1", mockConfig);
|
|
180
|
+
|
|
181
|
+
const result = await handler(
|
|
182
|
+
JSON.stringify({ task_id: "task-claimed", status: "claimed" }),
|
|
183
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.claimed" }
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(result.status).toBe(200);
|
|
187
|
+
expect(api.postComment).toHaveBeenCalledWith(
|
|
188
|
+
"issue-claimed",
|
|
189
|
+
expect.stringContaining("claimed")
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("handles task.in_progress event with progress", async () => {
|
|
194
|
+
const api = mockApi();
|
|
195
|
+
const handler = createWebhookHandler({
|
|
196
|
+
webhookSecret: "secret",
|
|
197
|
+
api,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
registerTaskMapping("task-progress", "issue-progress", "c1", mockConfig);
|
|
201
|
+
|
|
202
|
+
const result = await handler(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
task_id: "task-progress",
|
|
205
|
+
status: "in_progress",
|
|
206
|
+
progress_percent: 50,
|
|
207
|
+
progress_message: "Halfway there",
|
|
208
|
+
}),
|
|
209
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.in_progress" }
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(result.status).toBe(200);
|
|
213
|
+
expect(api.postComment).toHaveBeenCalledWith(
|
|
214
|
+
"issue-progress",
|
|
215
|
+
expect.stringContaining("50%")
|
|
216
|
+
);
|
|
217
|
+
expect(api.postComment).toHaveBeenCalledWith(
|
|
218
|
+
"issue-progress",
|
|
219
|
+
expect.stringContaining("Halfway there")
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("handles task.completed event", async () => {
|
|
224
|
+
const api = mockApi();
|
|
225
|
+
const handler = createWebhookHandler({
|
|
226
|
+
webhookSecret: "secret",
|
|
227
|
+
api,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
registerTaskMapping("task-done", "issue-done", "c1", mockConfig);
|
|
231
|
+
|
|
232
|
+
const result = await handler(
|
|
233
|
+
JSON.stringify({
|
|
234
|
+
task_id: "task-done",
|
|
235
|
+
status: "completed",
|
|
236
|
+
output: "All done!",
|
|
237
|
+
}),
|
|
238
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.completed" }
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(result.status).toBe(200);
|
|
242
|
+
expect(api.updateIssue).toHaveBeenCalledWith("issue-done", {
|
|
243
|
+
status: "done",
|
|
244
|
+
comment: expect.stringContaining("All done!"),
|
|
245
|
+
});
|
|
246
|
+
// Task should be unregistered after completion
|
|
247
|
+
expect(getActiveTaskMappings().has("task-done")).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("handles task.failed event", async () => {
|
|
251
|
+
const api = mockApi();
|
|
252
|
+
const handler = createWebhookHandler({
|
|
253
|
+
webhookSecret: "secret",
|
|
254
|
+
api,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
registerTaskMapping("task-fail", "issue-fail", "c1", mockConfig);
|
|
258
|
+
|
|
259
|
+
const result = await handler(
|
|
260
|
+
JSON.stringify({
|
|
261
|
+
task_id: "task-fail",
|
|
262
|
+
status: "failed",
|
|
263
|
+
error_reason: "Something broke",
|
|
264
|
+
}),
|
|
265
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.failed" }
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(result.status).toBe(200);
|
|
269
|
+
expect(api.updateIssue).toHaveBeenCalledWith("issue-fail", {
|
|
270
|
+
status: "blocked",
|
|
271
|
+
comment: expect.stringContaining("Something broke"),
|
|
272
|
+
});
|
|
273
|
+
expect(getActiveTaskMappings().has("task-fail")).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("handles task.cancelled event", async () => {
|
|
277
|
+
const api = mockApi();
|
|
278
|
+
const handler = createWebhookHandler({
|
|
279
|
+
webhookSecret: "secret",
|
|
280
|
+
api,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
registerTaskMapping("task-cancel", "issue-cancel", "c1", mockConfig);
|
|
284
|
+
|
|
285
|
+
const result = await handler(
|
|
286
|
+
JSON.stringify({ task_id: "task-cancel", status: "cancelled" }),
|
|
287
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.cancelled" }
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(result.status).toBe(200);
|
|
291
|
+
expect(api.updateIssue).toHaveBeenCalledWith("issue-cancel", {
|
|
292
|
+
status: "cancelled",
|
|
293
|
+
comment: expect.stringContaining("cancelled"),
|
|
294
|
+
});
|
|
295
|
+
expect(getActiveTaskMappings().has("task-cancel")).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("calls onUnknownEvent for unrecognized event types", async () => {
|
|
299
|
+
const api = mockApi();
|
|
300
|
+
const onUnknownEvent = vi.fn();
|
|
301
|
+
const handler = createWebhookHandler({
|
|
302
|
+
webhookSecret: "secret",
|
|
303
|
+
api,
|
|
304
|
+
onUnknownEvent,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
registerTaskMapping("task-unknown", "issue-unknown", "c1", mockConfig);
|
|
308
|
+
|
|
309
|
+
const result = await handler(
|
|
310
|
+
JSON.stringify({ task_id: "task-unknown", status: "custom" }),
|
|
311
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.custom_event" }
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
expect(result.status).toBe(200);
|
|
315
|
+
expect(onUnknownEvent).toHaveBeenCalledWith(
|
|
316
|
+
"task.custom_event",
|
|
317
|
+
expect.objectContaining({ task_id: "task-unknown" })
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("returns 500 when event handler throws", async () => {
|
|
322
|
+
const api = mockApi();
|
|
323
|
+
api.postComment.mockRejectedValueOnce(new Error("API down"));
|
|
324
|
+
const handler = createWebhookHandler({
|
|
325
|
+
webhookSecret: "secret",
|
|
326
|
+
api,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
registerTaskMapping("task-err", "issue-err", "c1", mockConfig);
|
|
330
|
+
|
|
331
|
+
const result = await handler(
|
|
332
|
+
JSON.stringify({ task_id: "task-err", status: "pending" }),
|
|
333
|
+
{ "x-webhook-signature": "sig", "x-webhook-event": "task.created" }
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
expect(result.status).toBe(500);
|
|
337
|
+
expect(result.body).toBe("Internal error");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Case-insensitive header handling
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
describe("header handling", () => {
|
|
346
|
+
it("reads signature from X-Webhook-Signature (capitalized)", async () => {
|
|
347
|
+
const api = mockApi();
|
|
348
|
+
const handler = createWebhookHandler({
|
|
349
|
+
webhookSecret: "secret",
|
|
350
|
+
api,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const result = await handler(
|
|
354
|
+
JSON.stringify({ task_id: "t1", status: "completed" }),
|
|
355
|
+
{ "X-Webhook-Signature": "sig", "X-Webhook-Event": "task.completed" }
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// Should get past signature check (not 401)
|
|
359
|
+
expect(result.status).not.toBe(401);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reads event from X-Webhook-Event (capitalized)", async () => {
|
|
363
|
+
const api = mockApi();
|
|
364
|
+
const handler = createWebhookHandler({
|
|
365
|
+
webhookSecret: "secret",
|
|
366
|
+
api,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
registerTaskMapping("t2", "i2", "c1", mockConfig);
|
|
370
|
+
|
|
371
|
+
const result = await handler(
|
|
372
|
+
JSON.stringify({ task_id: "t2", status: "pending" }),
|
|
373
|
+
{ "x-webhook-signature": "sig", "X-Webhook-Event": "task.created" }
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
expect(result.status).toBe(200);
|
|
377
|
+
expect(api.postComment).toHaveBeenCalled();
|
|
378
|
+
});
|
|
379
|
+
});
|