@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 CHANGED
@@ -1,17 +1,27 @@
1
1
  # @siftline/actions
2
2
 
3
- Siftline adapters for the places labels land: issue trackers, inboxes and queues.
3
+ Siftline adapters that carry a Decision to a webhook or to Slack.
4
4
 
5
- This package is a walking skeleton. It exports a placeholder constant built from
6
- `@siftline/core`'s own placeholder, which is enough to prove the build, type, test, pack
7
- and publish path and that the dependency on the engine resolves. The adapters land next.
5
+ ```sh
6
+ npm install @siftline/actions
7
+ ```
8
8
 
9
9
  ```ts
10
- import { PLACEHOLDER } from "@siftline/actions";
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 see [LICENSE](./LICENSE).
27
+ MIT. See [LICENSE](./LICENSE).
package/dist/index.d.mts CHANGED
@@ -1,9 +1,86 @@
1
- //#region src/index.d.ts
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
- * Walking-skeleton export. It exists so the build, type, test, pack and publish path
4
- * can be proven before any adapter is written, and it will be deleted when the first
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 PLACEHOLDER = "siftline-actions-walking-skeleton(siftline-core-walking-skeleton)";
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 { PLACEHOLDER as PLACEHOLDER$1 } from "@siftline/core";
2
- //#region src/index.ts
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
- * Walking-skeleton export. It exists so the build, type, test, pack and publish path
5
- * can be proven before any adapter is written, and it will be deleted when the first
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 PLACEHOLDER = `siftline-actions-walking-skeleton(${PLACEHOLDER$1})`;
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 { PLACEHOLDER };
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.0.2",
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.0.3"
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
  }