@siftline/actions 0.0.2 → 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/README.md +16 -6
- package/dist/index.d.mts +84 -7
- package/dist/index.mjs +159 -8
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
# @siftline/actions
|
|
2
2
|
|
|
3
|
-
Siftline adapters
|
|
3
|
+
Siftline adapters that carry a Decision to a webhook or to Slack.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
```sh
|
|
6
|
+
npm install @siftline/actions
|
|
7
|
+
```
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
import {
|
|
10
|
+
import { perform, webhook } from "@siftline/actions";
|
|
11
|
+
|
|
12
|
+
const request = await webhook.build(decision, { url, secret }, recipe);
|
|
13
|
+
const { status, body, truncated } = await perform(request, fetch);
|
|
11
14
|
```
|
|
12
15
|
|
|
16
|
+
`build` is pure and `perform` sends once, so a preview is `build` without `perform`. The
|
|
17
|
+
webhook body is the Decision line, signed with HMAC-SHA256 when a `secret` is set;
|
|
18
|
+
`slackIncomingWebhook` posts one message.
|
|
19
|
+
|
|
20
|
+
The guide and the full reference live at
|
|
21
|
+
[docs.siftline.dev](https://docs.siftline.dev/docs/packages/actions).
|
|
22
|
+
|
|
13
23
|
ESM only. Node 22.14 or newer.
|
|
14
24
|
|
|
15
25
|
## Licence
|
|
16
26
|
|
|
17
|
-
MIT
|
|
27
|
+
MIT. See [LICENSE](./LICENSE).
|
package/dist/index.d.mts
CHANGED
|
@@ -1,9 +1,86 @@
|
|
|
1
|
-
|
|
1
|
+
import { ZodType } from "zod";
|
|
2
|
+
import { Decision, Recipe, SiftlineError } from "@siftline/core";
|
|
3
|
+
//#region src/adapter.d.ts
|
|
4
|
+
/** Cloud's `action.kind` strings. */
|
|
5
|
+
type ActionKind = "webhook" | "slack_incoming_webhook";
|
|
6
|
+
/** Everything needed to send an Action, and nothing that depends on a clock or randomness. */
|
|
7
|
+
interface ActionRequest {
|
|
8
|
+
method: "POST";
|
|
9
|
+
url: string;
|
|
10
|
+
headers: {
|
|
11
|
+
[name: string]: string;
|
|
12
|
+
};
|
|
13
|
+
body: string;
|
|
14
|
+
idempotencyKey: string;
|
|
15
|
+
}
|
|
16
|
+
interface Adapter<C> {
|
|
17
|
+
kind: ActionKind;
|
|
18
|
+
configSchema: ZodType<C>;
|
|
19
|
+
build: (decision: Decision, config: C, recipe: Recipe) => Promise<ActionRequest>;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/webhook.d.ts
|
|
23
|
+
interface WebhookConfig {
|
|
24
|
+
url: string;
|
|
25
|
+
secret?: string;
|
|
26
|
+
headers?: {
|
|
27
|
+
[name: string]: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** POSTs the Decision line itself, with no envelope, so a receiver runs `parseDecision`. */
|
|
31
|
+
export declare const webhook: Adapter<WebhookConfig>;
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/slack.d.ts
|
|
34
|
+
interface SlackIncomingWebhookConfig {
|
|
35
|
+
url: string;
|
|
36
|
+
}
|
|
2
37
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* adapter lands. The engine's own placeholder is folded in so the caret dependency on
|
|
6
|
-
* `@siftline/core` is exercised at runtime, not merely declared.
|
|
38
|
+
* Posts one mrkdwn message, no blocks. Slack itself has no idempotency, so a redelivery
|
|
39
|
+
* past cloud's guard posts twice.
|
|
7
40
|
*/
|
|
8
|
-
export declare const
|
|
9
|
-
//#endregion
|
|
41
|
+
export declare const slackIncomingWebhook: Adapter<SlackIncomingWebhookConfig>;
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/adapters.d.ts
|
|
44
|
+
/** Every Adapter, keyed by cloud's `action.kind`. */
|
|
45
|
+
export declare const adapters: {
|
|
46
|
+
webhook: Adapter<WebhookConfig>;
|
|
47
|
+
slack_incoming_webhook: Adapter<SlackIncomingWebhookConfig>;
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/errors.d.ts
|
|
51
|
+
/** A Decision that cannot become a request. Never retryable: nothing about it will change. */
|
|
52
|
+
export declare class ActionBuildError extends SiftlineError {
|
|
53
|
+
constructor(message: string, options?: ErrorOptions);
|
|
54
|
+
}
|
|
55
|
+
/** A request that was sent and did not land. `status` is null when `fetch` itself threw. */
|
|
56
|
+
export declare class ActionFailedError extends SiftlineError {
|
|
57
|
+
readonly status: number | null;
|
|
58
|
+
constructor(message: string, status: number | null, retryable: boolean, options?: ErrorOptions);
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/perform.d.ts
|
|
62
|
+
interface ActionResponse {
|
|
63
|
+
status: number;
|
|
64
|
+
body: string;
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
}
|
|
67
|
+
interface ActionFetchInit {
|
|
68
|
+
method: "POST";
|
|
69
|
+
headers: {
|
|
70
|
+
[name: string]: string;
|
|
71
|
+
};
|
|
72
|
+
body: string;
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
}
|
|
75
|
+
/** Narrower than the global `fetch`, which is assignable to it, so a stub needs no cast. */
|
|
76
|
+
type ActionFetch = (url: string, init: ActionFetchInit) => Promise<Response>;
|
|
77
|
+
/** Sends the request once. No retries, no timeout of its own: both belong to the caller. */
|
|
78
|
+
export declare function perform(request: ActionRequest, fetchImpl: ActionFetch, options?: {
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
}): Promise<ActionResponse>;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/version.d.ts
|
|
83
|
+
/** The published version of `@siftline/actions`, baked in at build time. */
|
|
84
|
+
export declare const VERSION: string;
|
|
85
|
+
//#endregion
|
|
86
|
+
export type { ActionFetch, ActionFetchInit, ActionKind, ActionRequest, ActionResponse, Adapter, SlackIncomingWebhookConfig, WebhookConfig };
|
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,162 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SiftlineError, serializeDecision } from "@siftline/core";
|
|
3
|
+
//#region src/errors.ts
|
|
4
|
+
/** A Decision that cannot become a request. Never retryable: nothing about it will change. */
|
|
5
|
+
var ActionBuildError = class extends SiftlineError {
|
|
6
|
+
constructor(message, options) {
|
|
7
|
+
super(message, "action_build", false, options);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
/** A request that was sent and did not land. `status` is null when `fetch` itself threw. */
|
|
11
|
+
var ActionFailedError = class extends SiftlineError {
|
|
12
|
+
status;
|
|
13
|
+
constructor(message, status, retryable, options) {
|
|
14
|
+
super(message, "action_failed", retryable, options);
|
|
15
|
+
this.status = status;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/version.ts
|
|
20
|
+
/** The published version of `@siftline/actions`, baked in at build time. */
|
|
21
|
+
const VERSION = "0.1.1";
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/adapter.ts
|
|
24
|
+
/** The key the receiver deduplicates on, and the `Idempotency-Key` header. */
|
|
25
|
+
function idempotencyKeyFor(decision) {
|
|
26
|
+
if (decision.action === null) throw new ActionBuildError(`Decision ${decision.id} selected no Action`);
|
|
27
|
+
return `${decision.id}:${decision.action}`;
|
|
28
|
+
}
|
|
29
|
+
/** The three headers every adapter sends. An adapter adds its own on top. */
|
|
30
|
+
function baseHeaders(idempotencyKey) {
|
|
31
|
+
return {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"Idempotency-Key": idempotencyKey,
|
|
34
|
+
"User-Agent": `siftline-actions/${VERSION}`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const encoder = new TextEncoder();
|
|
38
|
+
async function hmacSha256Hex(secret, body) {
|
|
39
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
40
|
+
name: "HMAC",
|
|
41
|
+
hash: "SHA-256"
|
|
42
|
+
}, false, ["sign"]);
|
|
43
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
44
|
+
return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/slack.ts
|
|
48
|
+
const slackIncomingWebhookConfigSchema = z.object({ url: z.url() }).strict();
|
|
49
|
+
function describeLevel(index, criteria) {
|
|
50
|
+
const description = criteria[index];
|
|
51
|
+
if (description === void 0) return String(index);
|
|
52
|
+
return `${index} · ${description === null || description instanceof Object ? JSON.stringify(description) : description}`;
|
|
53
|
+
}
|
|
54
|
+
function renderAnswer(question, answer) {
|
|
55
|
+
if (question.type === "noul") return answer ? "yes" : "no";
|
|
56
|
+
if (question.type === "score") return describeLevel(Number(answer), question.criteria);
|
|
57
|
+
return String(answer);
|
|
58
|
+
}
|
|
59
|
+
function renderText(decision, recipe) {
|
|
60
|
+
const lines = [`*${recipe.name}* · ${decision.recordId}`];
|
|
61
|
+
for (const [name, question] of Object.entries(recipe.questions)) {
|
|
62
|
+
const answer = decision.answers[name];
|
|
63
|
+
const evidence = decision.questions[name];
|
|
64
|
+
if (answer === void 0 || evidence === void 0) continue;
|
|
65
|
+
const percent = Math.round(evidence.confidence * 100);
|
|
66
|
+
lines.push(`${name}: ${renderAnswer(question, answer)} (${percent}%)`);
|
|
67
|
+
}
|
|
68
|
+
if (decision.rule !== null) lines.push(`rule ${decision.rule}`);
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
async function build$1(decision, config, recipe) {
|
|
72
|
+
const idempotencyKey = idempotencyKeyFor(decision);
|
|
73
|
+
return {
|
|
74
|
+
method: "POST",
|
|
75
|
+
url: config.url,
|
|
76
|
+
headers: baseHeaders(idempotencyKey),
|
|
77
|
+
body: JSON.stringify({ text: renderText(decision, recipe) }),
|
|
78
|
+
idempotencyKey
|
|
79
|
+
};
|
|
80
|
+
}
|
|
3
81
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* adapter lands. The engine's own placeholder is folded in so the caret dependency on
|
|
7
|
-
* `@siftline/core` is exercised at runtime, not merely declared.
|
|
82
|
+
* Posts one mrkdwn message, no blocks. Slack itself has no idempotency, so a redelivery
|
|
83
|
+
* past cloud's guard posts twice.
|
|
8
84
|
*/
|
|
9
|
-
const
|
|
85
|
+
const slackIncomingWebhook = {
|
|
86
|
+
kind: "slack_incoming_webhook",
|
|
87
|
+
configSchema: slackIncomingWebhookConfigSchema,
|
|
88
|
+
build: build$1
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/webhook.ts
|
|
92
|
+
const webhookConfigSchema = z.object({
|
|
93
|
+
url: z.url(),
|
|
94
|
+
secret: z.string().min(1).optional(),
|
|
95
|
+
headers: z.record(z.string().min(1), z.string()).optional()
|
|
96
|
+
}).strict();
|
|
97
|
+
async function build(decision, config, _recipe) {
|
|
98
|
+
const idempotencyKey = idempotencyKeyFor(decision);
|
|
99
|
+
const body = serializeDecision(decision);
|
|
100
|
+
const headers = baseHeaders(idempotencyKey);
|
|
101
|
+
for (const [name, value] of Object.entries(config.headers ?? {})) headers[name] = value;
|
|
102
|
+
if (config.secret !== void 0) headers["X-Siftline-Signature"] = `sha256=${await hmacSha256Hex(config.secret, body)}`;
|
|
103
|
+
return {
|
|
104
|
+
method: "POST",
|
|
105
|
+
url: config.url,
|
|
106
|
+
headers,
|
|
107
|
+
body,
|
|
108
|
+
idempotencyKey
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** POSTs the Decision line itself, with no envelope, so a receiver runs `parseDecision`. */
|
|
112
|
+
const webhook = {
|
|
113
|
+
kind: "webhook",
|
|
114
|
+
configSchema: webhookConfigSchema,
|
|
115
|
+
build
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/adapters.ts
|
|
119
|
+
/** Every Adapter, keyed by cloud's `action.kind`. */
|
|
120
|
+
const adapters = {
|
|
121
|
+
webhook,
|
|
122
|
+
slack_incoming_webhook: slackIncomingWebhook
|
|
123
|
+
};
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/perform.ts
|
|
126
|
+
const BODY_LIMIT = 4096;
|
|
127
|
+
const decoder = new TextDecoder();
|
|
128
|
+
function truncate(bytes) {
|
|
129
|
+
let end = BODY_LIMIT;
|
|
130
|
+
while (end > 0 && ((bytes[end] ?? 0) & 192) === 128) end -= 1;
|
|
131
|
+
return decoder.decode(bytes.subarray(0, end));
|
|
132
|
+
}
|
|
133
|
+
function retryableStatus(status) {
|
|
134
|
+
return status === 408 || status === 429 || status >= 500;
|
|
135
|
+
}
|
|
136
|
+
/** Sends the request once. No retries, no timeout of its own: both belong to the caller. */
|
|
137
|
+
async function perform(request, fetchImpl, options = {}) {
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await fetchImpl(request.url, {
|
|
141
|
+
method: request.method,
|
|
142
|
+
headers: request.headers,
|
|
143
|
+
body: request.body,
|
|
144
|
+
signal: options.signal
|
|
145
|
+
});
|
|
146
|
+
} catch (cause) {
|
|
147
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
148
|
+
throw new ActionFailedError(`POST ${request.url} failed: ${reason}`, null, true, { cause });
|
|
149
|
+
}
|
|
150
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
151
|
+
const truncated = bytes.byteLength > BODY_LIMIT;
|
|
152
|
+
const body = truncated ? truncate(bytes) : decoder.decode(bytes);
|
|
153
|
+
const result = {
|
|
154
|
+
status: response.status,
|
|
155
|
+
body,
|
|
156
|
+
truncated
|
|
157
|
+
};
|
|
158
|
+
if (!response.ok) throw new ActionFailedError(`POST ${request.url} returned ${response.status}`, response.status, retryableStatus(response.status), { cause: result });
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
10
161
|
//#endregion
|
|
11
|
-
export {
|
|
162
|
+
export { ActionBuildError, ActionFailedError, VERSION, adapters, perform, slackIncomingWebhook, webhook };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siftline/actions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Siftline adapters for the places labels land: issue trackers, inboxes and queues.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"actions",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@siftline/core": "^0.
|
|
45
|
+
"@siftline/core": "^0.1.1",
|
|
46
|
+
"zod": "4.6.5"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@arethetypeswrong/core": "0.18.5",
|
|
@@ -54,5 +55,6 @@
|
|
|
54
55
|
},
|
|
55
56
|
"engines": {
|
|
56
57
|
"node": ">=22.14"
|
|
57
|
-
}
|
|
58
|
+
},
|
|
59
|
+
"//zod": "Direct, though core already depends on it: `Adapter<C>.configSchema` is a `ZodType<C>`, so zod is in this package's public types and must resolve from here. The spec's \"actions adds none beyond core\" is owed a correction."
|
|
58
60
|
}
|