@twelvehart/orcats 0.0.0-seed.0 → 0.2.3
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/LICENSE +48 -0
- package/NOTICE +5 -0
- package/README.md +399 -0
- package/bin/orcats +4 -0
- package/dist/backends/acp-client.d.ts +74 -0
- package/dist/backends/acp-client.d.ts.map +1 -0
- package/dist/backends/acp-run.d.ts +31 -0
- package/dist/backends/acp-run.d.ts.map +1 -0
- package/dist/backends/claude-run.d.ts +35 -0
- package/dist/backends/claude-run.d.ts.map +1 -0
- package/dist/backends/claude-stream-json.d.ts +15 -0
- package/dist/backends/claude-stream-json.d.ts.map +1 -0
- package/dist/backends/codex-jsonl.d.ts +34 -0
- package/dist/backends/codex-jsonl.d.ts.map +1 -0
- package/dist/backends/codex-run.d.ts +40 -0
- package/dist/backends/codex-run.d.ts.map +1 -0
- package/dist/backends/codex.d.ts +5 -0
- package/dist/backends/codex.d.ts.map +1 -0
- package/dist/backends/conversation-config.d.ts +12 -0
- package/dist/backends/conversation-config.d.ts.map +1 -0
- package/dist/backends/index.d.ts +14 -0
- package/dist/backends/index.d.ts.map +1 -0
- package/dist/backends/opencode-run.d.ts +48 -0
- package/dist/backends/opencode-run.d.ts.map +1 -0
- package/dist/backends/opencode-sse.d.ts +35 -0
- package/dist/backends/opencode-sse.d.ts.map +1 -0
- package/dist/backends/pi-rpc.d.ts +21 -0
- package/dist/backends/pi-rpc.d.ts.map +1 -0
- package/dist/backends/pi-run.d.ts +21 -0
- package/dist/backends/pi-run.d.ts.map +1 -0
- package/dist/backends/select.d.ts +17 -0
- package/dist/backends/select.d.ts.map +1 -0
- package/dist/backends/subprocess-run.d.ts +68 -0
- package/dist/backends/subprocess-run.d.ts.map +1 -0
- package/dist/backends/types.d.ts +17 -0
- package/dist/backends/types.d.ts.map +1 -0
- package/dist/backends/unsupported.d.ts +9 -0
- package/dist/backends/unsupported.d.ts.map +1 -0
- package/dist/baseline/index.d.ts +56 -0
- package/dist/baseline/index.d.ts.map +1 -0
- package/dist/cli/args.d.ts +22 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/embedded.d.ts +7 -0
- package/dist/cli/embedded.d.ts.map +1 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/version.d.ts +2 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/conversation/ask-user.d.ts +17 -0
- package/dist/conversation/ask-user.d.ts.map +1 -0
- package/dist/conversation/conversation.d.ts +46 -0
- package/dist/conversation/conversation.d.ts.map +1 -0
- package/dist/conversation/harness.d.ts +14 -0
- package/dist/conversation/harness.d.ts.map +1 -0
- package/dist/conversation/index.d.ts +5 -0
- package/dist/conversation/index.d.ts.map +1 -0
- package/dist/conversation/queue.d.ts +15 -0
- package/dist/conversation/queue.d.ts.map +1 -0
- package/dist/flow/accessors.d.ts +13 -0
- package/dist/flow/accessors.d.ts.map +1 -0
- package/dist/flow/approvals.d.ts +6 -0
- package/dist/flow/approvals.d.ts.map +1 -0
- package/dist/flow/args.d.ts +4 -0
- package/dist/flow/args.d.ts.map +1 -0
- package/dist/flow/context.d.ts +36 -0
- package/dist/flow/context.d.ts.map +1 -0
- package/dist/flow/index.d.ts +5 -0
- package/dist/flow/index.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/loop/builder/index.d.ts +17 -0
- package/dist/loop/builder/index.d.ts.map +1 -0
- package/dist/loop/builder/types.d.ts +97 -0
- package/dist/loop/builder/types.d.ts.map +1 -0
- package/dist/loop/context/compaction.d.ts +38 -0
- package/dist/loop/context/compaction.d.ts.map +1 -0
- package/dist/loop/context/index.d.ts +4 -0
- package/dist/loop/context/index.d.ts.map +1 -0
- package/dist/loop/context/offload.d.ts +34 -0
- package/dist/loop/context/offload.d.ts.map +1 -0
- package/dist/loop/context/types.d.ts +13 -0
- package/dist/loop/context/types.d.ts.map +1 -0
- package/dist/loop/engine/bridge.d.ts +21 -0
- package/dist/loop/engine/bridge.d.ts.map +1 -0
- package/dist/loop/engine/index.d.ts +73 -0
- package/dist/loop/engine/index.d.ts.map +1 -0
- package/dist/loop/execution.d.ts +68 -0
- package/dist/loop/execution.d.ts.map +1 -0
- package/dist/loop/fanout.d.ts +148 -0
- package/dist/loop/fanout.d.ts.map +1 -0
- package/dist/loop/firing.d.ts +54 -0
- package/dist/loop/firing.d.ts.map +1 -0
- package/dist/loop/graph/analyze.d.ts +8 -0
- package/dist/loop/graph/analyze.d.ts.map +1 -0
- package/dist/loop/graph/index.d.ts +3 -0
- package/dist/loop/graph/index.d.ts.map +1 -0
- package/dist/loop/graph/types.d.ts +35 -0
- package/dist/loop/graph/types.d.ts.map +1 -0
- package/dist/loop/index.d.ts +7 -0
- package/dist/loop/index.d.ts.map +1 -0
- package/dist/loop/io/index.d.ts +4 -0
- package/dist/loop/io/index.d.ts.map +1 -0
- package/dist/loop/io/linear.d.ts +142 -0
- package/dist/loop/io/linear.d.ts.map +1 -0
- package/dist/loop/io/sink.d.ts +47 -0
- package/dist/loop/io/sink.d.ts.map +1 -0
- package/dist/loop/io/source.d.ts +64 -0
- package/dist/loop/io/source.d.ts.map +1 -0
- package/dist/loop/serve.d.ts +97 -0
- package/dist/loop/serve.d.ts.map +1 -0
- package/dist/loop/state/index.d.ts +5 -0
- package/dist/loop/state/index.d.ts.map +1 -0
- package/dist/loop/state/manifest.d.ts +36 -0
- package/dist/loop/state/manifest.d.ts.map +1 -0
- package/dist/loop/state/port.d.ts +33 -0
- package/dist/loop/state/port.d.ts.map +1 -0
- package/dist/loop/state/snapshot.d.ts +15 -0
- package/dist/loop/state/snapshot.d.ts.map +1 -0
- package/dist/loop/state/sqlite.d.ts +31 -0
- package/dist/loop/state/sqlite.d.ts.map +1 -0
- package/dist/loop/termination-contract.d.ts +28 -0
- package/dist/loop/termination-contract.d.ts.map +1 -0
- package/dist/loop/termination.d.ts +33 -0
- package/dist/loop/termination.d.ts.map +1 -0
- package/dist/model/backend-config.d.ts +30 -0
- package/dist/model/backend-config.d.ts.map +1 -0
- package/dist/model/brand.d.ts +9 -0
- package/dist/model/brand.d.ts.map +1 -0
- package/dist/model/errors.d.ts +11 -0
- package/dist/model/errors.d.ts.map +1 -0
- package/dist/model/index.d.ts +8 -0
- package/dist/model/index.d.ts.map +1 -0
- package/dist/model/json-schema.d.ts +4 -0
- package/dist/model/json-schema.d.ts.map +1 -0
- package/dist/model/result.d.ts +3 -0
- package/dist/model/result.d.ts.map +1 -0
- package/dist/model/schemas.d.ts +435 -0
- package/dist/model/schemas.d.ts.map +1 -0
- package/dist/model/structured.d.ts +9 -0
- package/dist/model/structured.d.ts.map +1 -0
- package/dist/monitor/index.d.ts +139 -0
- package/dist/monitor/index.d.ts.map +1 -0
- package/dist/plan/index.d.ts +3 -0
- package/dist/plan/index.d.ts.map +1 -0
- package/dist/plan/persistent.d.ts +23 -0
- package/dist/plan/persistent.d.ts.map +1 -0
- package/dist/plan/verdict.d.ts +9 -0
- package/dist/plan/verdict.d.ts.map +1 -0
- package/dist/review/index.d.ts +4 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/loop.d.ts +117 -0
- package/dist/review/loop.d.ts.map +1 -0
- package/dist/review/reviewers.d.ts +13 -0
- package/dist/review/reviewers.d.ts.map +1 -0
- package/dist/review/tool.d.ts +20 -0
- package/dist/review/tool.d.ts.map +1 -0
- package/dist/run-output/index.d.ts +112 -0
- package/dist/run-output/index.d.ts.map +1 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/typecheck.d.ts +23 -0
- package/dist/runner/typecheck.d.ts.map +1 -0
- package/dist/test-utils/fakes.d.ts +76 -0
- package/dist/test-utils/fakes.d.ts.map +1 -0
- package/dist/test-utils/fixtures.d.ts +4 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -0
- package/dist/test-utils/index.d.ts +4 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/tier2.d.ts +8 -0
- package/dist/test-utils/tier2.d.ts.map +1 -0
- package/dist/tools/fs.d.ts +11 -0
- package/dist/tools/fs.d.ts.map +1 -0
- package/dist/tools/git.d.ts +10 -0
- package/dist/tools/git.d.ts.map +1 -0
- package/dist/tools/github.d.ts +13 -0
- package/dist/tools/github.d.ts.map +1 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/linear.d.ts +117 -0
- package/dist/tools/linear.d.ts.map +1 -0
- package/dist/tools/process.d.ts +40 -0
- package/dist/tools/process.d.ts.map +1 -0
- package/dist/tools/terminal.d.ts +23 -0
- package/dist/tools/terminal.d.ts.map +1 -0
- package/package.json +89 -9
- package/src/backends/acp-client.ts +324 -0
- package/src/backends/acp-run.ts +636 -0
- package/src/backends/claude-run.ts +226 -0
- package/src/backends/claude-stream-json.ts +273 -0
- package/src/backends/codex-jsonl.ts +339 -0
- package/src/backends/codex-run.ts +177 -0
- package/src/backends/codex.ts +84 -0
- package/src/backends/conversation-config.ts +26 -0
- package/src/backends/index.ts +13 -0
- package/src/backends/opencode-run.ts +432 -0
- package/src/backends/opencode-sse.ts +360 -0
- package/src/backends/pi-rpc.ts +242 -0
- package/src/backends/pi-run.ts +154 -0
- package/src/backends/select.ts +77 -0
- package/src/backends/subprocess-run.ts +305 -0
- package/src/backends/types.ts +29 -0
- package/src/backends/unsupported.ts +48 -0
- package/src/baseline/index.ts +437 -0
- package/src/cli/args.ts +109 -0
- package/src/cli/embedded.ts +206 -0
- package/src/cli/main.ts +332 -0
- package/src/cli/version.ts +1 -0
- package/src/conversation/ask-user.ts +105 -0
- package/src/conversation/conversation.ts +109 -0
- package/src/conversation/harness.ts +39 -0
- package/src/conversation/index.ts +4 -0
- package/src/conversation/queue.ts +86 -0
- package/src/flow/accessors.ts +43 -0
- package/src/flow/approvals.ts +10 -0
- package/src/flow/args.ts +26 -0
- package/src/flow/context.ts +106 -0
- package/src/flow/index.ts +4 -0
- package/src/index.ts +17 -0
- package/src/loop/builder/index.ts +346 -0
- package/src/loop/builder/types.ts +131 -0
- package/src/loop/context/compaction.ts +175 -0
- package/src/loop/context/index.ts +3 -0
- package/src/loop/context/offload.ts +79 -0
- package/src/loop/context/types.ts +17 -0
- package/src/loop/engine/bridge.ts +61 -0
- package/src/loop/engine/index.ts +166 -0
- package/src/loop/execution.ts +453 -0
- package/src/loop/fanout.ts +444 -0
- package/src/loop/firing.ts +188 -0
- package/src/loop/graph/analyze.ts +128 -0
- package/src/loop/graph/index.ts +2 -0
- package/src/loop/graph/types.ts +45 -0
- package/src/loop/index.ts +11 -0
- package/src/loop/io/index.ts +3 -0
- package/src/loop/io/linear.ts +714 -0
- package/src/loop/io/sink.ts +170 -0
- package/src/loop/io/source.ts +255 -0
- package/src/loop/serve.ts +307 -0
- package/src/loop/state/index.ts +4 -0
- package/src/loop/state/manifest.ts +56 -0
- package/src/loop/state/port.ts +43 -0
- package/src/loop/state/snapshot.ts +129 -0
- package/src/loop/state/sqlite.ts +265 -0
- package/src/loop/termination-contract.ts +89 -0
- package/src/loop/termination.ts +156 -0
- package/src/model/backend-config.ts +34 -0
- package/src/model/brand.ts +11 -0
- package/src/model/errors.ts +28 -0
- package/src/model/index.ts +7 -0
- package/src/model/json-schema.ts +14 -0
- package/src/model/result.ts +28 -0
- package/src/model/schemas.ts +218 -0
- package/src/model/structured.ts +26 -0
- package/src/monitor/index.ts +367 -0
- package/src/plan/index.ts +2 -0
- package/src/plan/persistent.ts +98 -0
- package/src/plan/verdict.ts +3 -0
- package/src/review/index.ts +3 -0
- package/src/review/loop.ts +365 -0
- package/src/review/reviewers.ts +92 -0
- package/src/review/tool.ts +41 -0
- package/src/run-output/index.ts +319 -0
- package/src/runner/index.ts +1 -0
- package/src/runner/typecheck.ts +72 -0
- package/src/test-utils/fakes.ts +260 -0
- package/src/test-utils/fixtures.ts +33 -0
- package/src/test-utils/index.ts +3 -0
- package/src/test-utils/tier2.ts +12 -0
- package/src/tools/fs.ts +44 -0
- package/src/tools/git.ts +34 -0
- package/src/tools/github.ts +25 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/linear.ts +428 -0
- package/src/tools/process.ts +156 -0
- package/src/tools/terminal.ts +116 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
4
|
+
import { err, ok, type Result } from "neverthrow";
|
|
5
|
+
|
|
6
|
+
import { ioFailed, type RuntimeError } from "../../model/index.ts";
|
|
7
|
+
import { createLinearTool, type LinearExternalUrl, type LinearIssueUpdateInput, type LinearTool } from "../../tools/index.ts";
|
|
8
|
+
import type { Sink } from "./sink.ts";
|
|
9
|
+
import type { Source, SourceSubscription } from "./source.ts";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export interface LinearWebhookHeaders {
|
|
14
|
+
readonly [name: string]: string | readonly string[] | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LinearWebhookRequest {
|
|
18
|
+
readonly method: string;
|
|
19
|
+
readonly url: string;
|
|
20
|
+
readonly rawBody: string;
|
|
21
|
+
readonly headers: LinearWebhookHeaders;
|
|
22
|
+
readonly payload: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LinearWebhookDelivery {
|
|
26
|
+
readonly accepted: boolean;
|
|
27
|
+
readonly status: number;
|
|
28
|
+
readonly error?: RuntimeError;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type LinearWebhookRequestHandler = (
|
|
32
|
+
request: LinearWebhookRequest,
|
|
33
|
+
) => Promise<LinearWebhookDelivery>;
|
|
34
|
+
|
|
35
|
+
export interface LinearWebhookListener {
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LinearWebhookListenerFactory = (
|
|
40
|
+
onRequest: LinearWebhookRequestHandler,
|
|
41
|
+
) => Promise<Result<LinearWebhookListener, RuntimeError>>;
|
|
42
|
+
|
|
43
|
+
export interface LinearWebhookListenerOptions {
|
|
44
|
+
readonly port: number;
|
|
45
|
+
readonly path?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LinearWebhookVerificationOptions {
|
|
49
|
+
readonly request: LinearWebhookRequest;
|
|
50
|
+
readonly secret: string;
|
|
51
|
+
readonly now?: () => number;
|
|
52
|
+
readonly replayToleranceMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function linearWebhookSignature(secret: string, rawBody: string, timestamp: string): string {
|
|
56
|
+
return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function verifyLinearWebhookRequest(
|
|
60
|
+
options: LinearWebhookVerificationOptions,
|
|
61
|
+
): Result<void, RuntimeError> {
|
|
62
|
+
const signature = singleHeader(options.request.headers, "linear-signature");
|
|
63
|
+
if (signature === undefined || signature.length === 0) {
|
|
64
|
+
return err(linearSourceFailed("missing Linear-Signature header"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const timestamp = singleHeader(options.request.headers, "linear-timestamp");
|
|
68
|
+
if (timestamp === undefined || timestamp.length === 0) {
|
|
69
|
+
return err(linearSourceFailed("missing Linear-Timestamp header"));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const timestampMs = parseTimestampMs(timestamp);
|
|
73
|
+
if (timestampMs === undefined) {
|
|
74
|
+
return err(linearSourceFailed("invalid Linear-Timestamp header"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tolerance = options.replayToleranceMs ?? DEFAULT_REPLAY_TOLERANCE_MS;
|
|
78
|
+
const now = options.now?.() ?? Date.now();
|
|
79
|
+
if (Math.abs(now - timestampMs) > tolerance) {
|
|
80
|
+
return err(linearSourceFailed("stale Linear webhook timestamp"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const actual = normalizeSignature(signature);
|
|
84
|
+
const expected = linearWebhookSignature(options.secret, options.request.rawBody, timestamp);
|
|
85
|
+
if (!safeEqualHex(actual, expected)) {
|
|
86
|
+
return err(linearSourceFailed("invalid Linear webhook signature"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return ok(undefined);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createLinearWebhookListenerFactory(
|
|
93
|
+
options: LinearWebhookListenerOptions,
|
|
94
|
+
): LinearWebhookListenerFactory {
|
|
95
|
+
return (onRequest) =>
|
|
96
|
+
new Promise((resolve) => {
|
|
97
|
+
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
98
|
+
handleIncomingRequest(req, res, options, onRequest);
|
|
99
|
+
});
|
|
100
|
+
server.on("error", (error) => {
|
|
101
|
+
resolve(err(linearSourceFailed(String(error))));
|
|
102
|
+
});
|
|
103
|
+
server.listen(options.port, () => {
|
|
104
|
+
resolve(
|
|
105
|
+
ok({
|
|
106
|
+
close: () =>
|
|
107
|
+
new Promise<void>((done) => {
|
|
108
|
+
server.close(() => {
|
|
109
|
+
done();
|
|
110
|
+
});
|
|
111
|
+
}),
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface LinearActor {
|
|
119
|
+
readonly id?: string;
|
|
120
|
+
readonly name?: string;
|
|
121
|
+
readonly email?: string;
|
|
122
|
+
readonly type?: string;
|
|
123
|
+
readonly isApp?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface LinearIssueTriggerEvent {
|
|
127
|
+
readonly kind: "linear.issue";
|
|
128
|
+
readonly webhookType: "Issue" | "Comment";
|
|
129
|
+
readonly action: string;
|
|
130
|
+
readonly issueId: string;
|
|
131
|
+
readonly issueIdentifier?: string;
|
|
132
|
+
readonly issueUrl?: string;
|
|
133
|
+
readonly teamId?: string;
|
|
134
|
+
readonly projectId?: string;
|
|
135
|
+
readonly workflowStateId?: string;
|
|
136
|
+
readonly labelIds: readonly string[];
|
|
137
|
+
readonly actor?: LinearActor;
|
|
138
|
+
readonly dedupeKey: string;
|
|
139
|
+
readonly raw: unknown;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface LinearAgentIssueContext {
|
|
143
|
+
readonly id?: string;
|
|
144
|
+
readonly identifier?: string;
|
|
145
|
+
readonly url?: string;
|
|
146
|
+
readonly title?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface LinearAgentPromptContext {
|
|
150
|
+
readonly id?: string;
|
|
151
|
+
readonly body?: string;
|
|
152
|
+
readonly actor?: LinearActor;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface LinearAgentActivityContext {
|
|
156
|
+
readonly id?: string;
|
|
157
|
+
readonly type?: string;
|
|
158
|
+
readonly body?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type LinearAgentEventType = "session_created" | "session_prompted" | "activity" | "stop";
|
|
162
|
+
|
|
163
|
+
export interface LinearAgentTriggerEvent {
|
|
164
|
+
readonly kind: "linear.agent";
|
|
165
|
+
readonly eventType: LinearAgentEventType;
|
|
166
|
+
readonly action: string;
|
|
167
|
+
readonly agentSessionId: string;
|
|
168
|
+
readonly issue?: LinearAgentIssueContext;
|
|
169
|
+
readonly prompt?: LinearAgentPromptContext;
|
|
170
|
+
readonly activity?: LinearAgentActivityContext;
|
|
171
|
+
readonly isStopSignal: boolean;
|
|
172
|
+
readonly dedupeKey: string;
|
|
173
|
+
readonly raw: unknown;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface LinearSourceBaseOptions {
|
|
177
|
+
readonly webhookSecret: string;
|
|
178
|
+
readonly port?: number;
|
|
179
|
+
readonly path?: string;
|
|
180
|
+
readonly listenerFactory?: LinearWebhookListenerFactory;
|
|
181
|
+
readonly replayToleranceMs?: number;
|
|
182
|
+
readonly now?: () => number;
|
|
183
|
+
readonly seenDedupeKeys?: Set<string>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface LinearIssueSourceOptions extends LinearSourceBaseOptions {
|
|
187
|
+
readonly teamId?: string;
|
|
188
|
+
readonly teamIds?: readonly string[];
|
|
189
|
+
readonly projectId?: string;
|
|
190
|
+
readonly projectIds?: readonly string[];
|
|
191
|
+
readonly workflowStateId?: string;
|
|
192
|
+
readonly workflowStateIds?: readonly string[];
|
|
193
|
+
readonly labelIds?: readonly string[];
|
|
194
|
+
readonly actions?: readonly string[];
|
|
195
|
+
readonly selfActorId?: string;
|
|
196
|
+
readonly excludeActorIds?: readonly string[];
|
|
197
|
+
readonly excludeSelfActor?: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface LinearAgentSourceOptions extends LinearSourceBaseOptions {
|
|
201
|
+
readonly appUserId?: string;
|
|
202
|
+
readonly excludeActorIds?: readonly string[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function linearIssueSource(options: LinearIssueSourceOptions): Source<LinearIssueTriggerEvent> {
|
|
206
|
+
return linearWebhookSource("linear-issue", options, (request) => normalizeIssueEvent(request, options));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function linearAgentSource(options: LinearAgentSourceOptions): Source<LinearAgentTriggerEvent> {
|
|
210
|
+
return linearWebhookSource("linear-agent", options, (request) => normalizeAgentEvent(request, options));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface LinearIssueSinkOutput {
|
|
214
|
+
readonly issueId: string;
|
|
215
|
+
readonly finalSummary?: string;
|
|
216
|
+
readonly prUrl?: string;
|
|
217
|
+
readonly commentBody?: string;
|
|
218
|
+
readonly update?: Omit<LinearIssueUpdateInput, "issueId">;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface LinearIssueSinkOptions {
|
|
222
|
+
readonly tool?: LinearTool;
|
|
223
|
+
readonly commentPrefix?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function linearIssueSink(options: LinearIssueSinkOptions = {}): Sink<LinearIssueSinkOutput> {
|
|
227
|
+
return {
|
|
228
|
+
kind: "linear-issue",
|
|
229
|
+
async emit(output) {
|
|
230
|
+
const tool = options.tool ?? createLinearTool();
|
|
231
|
+
try {
|
|
232
|
+
const comment = renderIssueComment(output, options.commentPrefix);
|
|
233
|
+
if (comment !== undefined) {
|
|
234
|
+
const created = await tool.createIssueComment({ issueId: output.issueId, body: comment });
|
|
235
|
+
if (created.isErr()) return err(created.error);
|
|
236
|
+
}
|
|
237
|
+
if (output.update !== undefined) {
|
|
238
|
+
const updated = await tool.updateIssue({ issueId: output.issueId, ...output.update });
|
|
239
|
+
if (updated.isErr()) return err(updated.error);
|
|
240
|
+
}
|
|
241
|
+
return ok(undefined);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return err(linearSinkFailed(String(error)));
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface LinearAgentSinkOutput {
|
|
250
|
+
readonly agentSessionId: string;
|
|
251
|
+
readonly responseBody?: string;
|
|
252
|
+
readonly errorBody?: string;
|
|
253
|
+
readonly prUrl?: string;
|
|
254
|
+
readonly plan?: string;
|
|
255
|
+
readonly externalUrls?: readonly LinearExternalUrl[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface LinearAgentSinkOptions {
|
|
259
|
+
readonly tool?: LinearTool;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function linearAgentSink(options: LinearAgentSinkOptions = {}): Sink<LinearAgentSinkOutput> {
|
|
263
|
+
return {
|
|
264
|
+
kind: "linear-agent",
|
|
265
|
+
async emit(output) {
|
|
266
|
+
const tool = options.tool ?? createLinearTool();
|
|
267
|
+
try {
|
|
268
|
+
const body = output.errorBody ?? output.responseBody;
|
|
269
|
+
if (body !== undefined) {
|
|
270
|
+
const activity = await tool.createAgentActivity({
|
|
271
|
+
agentSessionId: output.agentSessionId,
|
|
272
|
+
body,
|
|
273
|
+
type: output.errorBody === undefined ? "response" : "error",
|
|
274
|
+
});
|
|
275
|
+
if (activity.isErr()) return err(activity.error);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const externalUrls = externalUrlsForOutput(output);
|
|
279
|
+
if (output.plan !== undefined || externalUrls.length > 0) {
|
|
280
|
+
const updated = await tool.updateAgentSession({
|
|
281
|
+
agentSessionId: output.agentSessionId,
|
|
282
|
+
...(output.plan === undefined ? {} : { plan: output.plan }),
|
|
283
|
+
...(externalUrls.length === 0 ? {} : { externalUrls }),
|
|
284
|
+
...(output.prUrl === undefined ? {} : { externalUrl: output.prUrl }),
|
|
285
|
+
});
|
|
286
|
+
if (updated.isErr()) return err(updated.error);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return ok(undefined);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return err(linearSinkFailed(String(error)));
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function linearWebhookSource<E>(
|
|
298
|
+
kind: "linear-issue" | "linear-agent",
|
|
299
|
+
options: LinearSourceBaseOptions,
|
|
300
|
+
normalize: (request: LinearWebhookRequest) => E | undefined,
|
|
301
|
+
): Source<E> {
|
|
302
|
+
const seen = options.seenDedupeKeys ?? new Set<string>();
|
|
303
|
+
const factory = options.listenerFactory ?? defaultLinearListenerFactory(options);
|
|
304
|
+
return {
|
|
305
|
+
kind,
|
|
306
|
+
async start(handler) {
|
|
307
|
+
if (factory === undefined) {
|
|
308
|
+
return err(linearSourceFailed("Linear source requires a port or listenerFactory"));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const listener = await factory((request) => {
|
|
312
|
+
const verified = verifyLinearWebhookRequest({
|
|
313
|
+
request,
|
|
314
|
+
secret: options.webhookSecret,
|
|
315
|
+
...(options.replayToleranceMs === undefined ? {} : { replayToleranceMs: options.replayToleranceMs }),
|
|
316
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
317
|
+
});
|
|
318
|
+
if (verified.isErr()) {
|
|
319
|
+
return Promise.resolve({ accepted: false, status: 401, error: verified.error });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const event = normalize(request);
|
|
323
|
+
if (event === undefined) {
|
|
324
|
+
return Promise.resolve({ accepted: false, status: 200 });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const dedupeKey = "dedupeKey" in Object(event) ? (event as { readonly dedupeKey: string }).dedupeKey : "";
|
|
328
|
+
if (seen.has(dedupeKey)) {
|
|
329
|
+
return Promise.resolve({ accepted: false, status: 200 });
|
|
330
|
+
}
|
|
331
|
+
seen.add(dedupeKey);
|
|
332
|
+
handler(event);
|
|
333
|
+
return Promise.resolve({ accepted: true, status: 200 });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (listener.isErr()) {
|
|
337
|
+
return err(listener.error);
|
|
338
|
+
}
|
|
339
|
+
const handle = listener.value;
|
|
340
|
+
const subscription: SourceSubscription = {
|
|
341
|
+
async stop() {
|
|
342
|
+
await handle.close();
|
|
343
|
+
return ok(undefined);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
return ok(subscription);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function defaultLinearListenerFactory(options: LinearSourceBaseOptions): LinearWebhookListenerFactory | undefined {
|
|
352
|
+
if (options.port === undefined) return undefined;
|
|
353
|
+
return createLinearWebhookListenerFactory({
|
|
354
|
+
port: options.port,
|
|
355
|
+
...(options.path === undefined ? {} : { path: options.path }),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function handleIncomingRequest(
|
|
360
|
+
req: IncomingMessage,
|
|
361
|
+
res: ServerResponse,
|
|
362
|
+
options: LinearWebhookListenerOptions,
|
|
363
|
+
onRequest: LinearWebhookRequestHandler,
|
|
364
|
+
): void {
|
|
365
|
+
const url = req.url ?? "/";
|
|
366
|
+
if (options.path !== undefined && url !== options.path) {
|
|
367
|
+
res.statusCode = 404;
|
|
368
|
+
res.end();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const chunks: Buffer[] = [];
|
|
373
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
374
|
+
req.on("end", () => {
|
|
375
|
+
void (async () => {
|
|
376
|
+
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
377
|
+
const parsed = parseJson(rawBody);
|
|
378
|
+
if (parsed.isErr()) {
|
|
379
|
+
res.statusCode = 400;
|
|
380
|
+
res.end("invalid json");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const delivery = await onRequest({
|
|
384
|
+
method: req.method ?? "GET",
|
|
385
|
+
url,
|
|
386
|
+
rawBody,
|
|
387
|
+
headers: { ...req.headers },
|
|
388
|
+
payload: parsed.value,
|
|
389
|
+
});
|
|
390
|
+
res.statusCode = delivery.status;
|
|
391
|
+
res.end(delivery.error === undefined ? "ok" : JSON.stringify(delivery.error));
|
|
392
|
+
})();
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function parseJson(rawBody: string): Result<unknown, RuntimeError> {
|
|
397
|
+
try {
|
|
398
|
+
return ok(JSON.parse(rawBody) as unknown);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return err(linearSourceFailed(String(error)));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function normalizeIssueEvent(
|
|
405
|
+
request: LinearWebhookRequest,
|
|
406
|
+
options: LinearIssueSourceOptions,
|
|
407
|
+
): LinearIssueTriggerEvent | undefined {
|
|
408
|
+
const payload = asRecord(request.payload);
|
|
409
|
+
const data = recordField(payload, "data") ?? payload;
|
|
410
|
+
const webhookType = stringField(payload, "type") ?? stringField(data, "type");
|
|
411
|
+
if (webhookType !== "Issue" && webhookType !== "Comment") return undefined;
|
|
412
|
+
|
|
413
|
+
const issue = webhookType === "Comment" ? recordField(data, "issue") ?? data : data;
|
|
414
|
+
const issueId = stringField(issue, "id") ?? stringField(data, "issueId");
|
|
415
|
+
if (issueId === undefined) return undefined;
|
|
416
|
+
|
|
417
|
+
const action = stringField(payload, "action") ?? stringField(data, "action") ?? "unknown";
|
|
418
|
+
const teamId = stringField(recordField(issue, "team"), "id") ?? stringField(issue, "teamId");
|
|
419
|
+
const projectId = stringField(recordField(issue, "project"), "id") ?? stringField(issue, "projectId");
|
|
420
|
+
const workflowStateId = stringField(recordField(issue, "state"), "id") ?? stringField(issue, "stateId");
|
|
421
|
+
const labelIds = labelsFromIssue(issue);
|
|
422
|
+
const actor = normalizeActor(recordField(payload, "actor") ?? recordField(data, "actor"));
|
|
423
|
+
|
|
424
|
+
if (!matchesOne(teamId, optionList(options.teamId, options.teamIds))) return undefined;
|
|
425
|
+
if (!matchesOne(projectId, optionList(options.projectId, options.projectIds))) return undefined;
|
|
426
|
+
if (!matchesOne(workflowStateId, optionList(options.workflowStateId, options.workflowStateIds))) return undefined;
|
|
427
|
+
if (!matchesLabels(labelIds, options.labelIds)) return undefined;
|
|
428
|
+
if (options.actions !== undefined && !options.actions.includes(action)) return undefined;
|
|
429
|
+
if (actorExcluded(actor, options.excludeActorIds, options.excludeSelfActor === true ? options.selfActorId : undefined)) {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const issueIdentifier = stringField(issue, "identifier");
|
|
434
|
+
const issueUrl = stringField(issue, "url");
|
|
435
|
+
let event: LinearIssueTriggerEvent = {
|
|
436
|
+
kind: "linear.issue",
|
|
437
|
+
webhookType,
|
|
438
|
+
action,
|
|
439
|
+
issueId,
|
|
440
|
+
labelIds,
|
|
441
|
+
dedupeKey: dedupeKey(request, payload, `${webhookType}:${action}:${issueId}`),
|
|
442
|
+
raw: request.payload,
|
|
443
|
+
};
|
|
444
|
+
if (issueIdentifier !== undefined) event = { ...event, issueIdentifier };
|
|
445
|
+
if (issueUrl !== undefined) event = { ...event, issueUrl };
|
|
446
|
+
if (teamId !== undefined) event = { ...event, teamId };
|
|
447
|
+
if (projectId !== undefined) event = { ...event, projectId };
|
|
448
|
+
if (workflowStateId !== undefined) event = { ...event, workflowStateId };
|
|
449
|
+
if (actor !== undefined) event = { ...event, actor };
|
|
450
|
+
return event;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function normalizeAgentEvent(
|
|
454
|
+
request: LinearWebhookRequest,
|
|
455
|
+
options: LinearAgentSourceOptions,
|
|
456
|
+
): LinearAgentTriggerEvent | undefined {
|
|
457
|
+
const payload = asRecord(request.payload);
|
|
458
|
+
const data = recordField(payload, "data") ?? payload;
|
|
459
|
+
const session = recordField(data, "agentSession") ?? recordField(payload, "agentSession") ?? data;
|
|
460
|
+
const agentSessionId =
|
|
461
|
+
stringField(data, "agentSessionId") ??
|
|
462
|
+
stringField(payload, "agentSessionId") ??
|
|
463
|
+
stringField(session, "id");
|
|
464
|
+
if (agentSessionId === undefined) return undefined;
|
|
465
|
+
|
|
466
|
+
const action = stringField(payload, "action") ?? stringField(data, "action") ?? "unknown";
|
|
467
|
+
const actor = normalizeActor(recordField(payload, "actor") ?? recordField(data, "actor"));
|
|
468
|
+
if (actorExcluded(actor, options.excludeActorIds, options.appUserId)) return undefined;
|
|
469
|
+
|
|
470
|
+
const activityRecord = recordField(data, "activity") ?? recordField(data, "agentActivity");
|
|
471
|
+
const promptRecord = recordField(data, "prompt") ?? recordField(payload, "prompt");
|
|
472
|
+
const activity = normalizeActivity(activityRecord);
|
|
473
|
+
const prompt = normalizePrompt(promptRecord, actor);
|
|
474
|
+
const isStopSignal = isStop(action, activity, prompt, data);
|
|
475
|
+
const eventType = agentEventType(action, isStopSignal, activity);
|
|
476
|
+
const issue = normalizeIssueContext(recordField(session, "issue") ?? recordField(data, "issue"));
|
|
477
|
+
|
|
478
|
+
let event: LinearAgentTriggerEvent = {
|
|
479
|
+
kind: "linear.agent",
|
|
480
|
+
eventType,
|
|
481
|
+
action,
|
|
482
|
+
agentSessionId,
|
|
483
|
+
isStopSignal,
|
|
484
|
+
dedupeKey: dedupeKey(request, payload, `Agent:${action}:${agentSessionId}`),
|
|
485
|
+
raw: request.payload,
|
|
486
|
+
};
|
|
487
|
+
if (issue !== undefined) event = { ...event, issue };
|
|
488
|
+
if (prompt !== undefined) event = { ...event, prompt };
|
|
489
|
+
if (activity !== undefined) event = { ...event, activity };
|
|
490
|
+
return event;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function normalizeIssueContext(record: Readonly<Record<string, unknown>> | undefined): LinearAgentIssueContext | undefined {
|
|
494
|
+
if (record === undefined) return undefined;
|
|
495
|
+
let context: LinearAgentIssueContext = {};
|
|
496
|
+
const id = stringField(record, "id");
|
|
497
|
+
const identifier = stringField(record, "identifier");
|
|
498
|
+
const url = stringField(record, "url");
|
|
499
|
+
const title = stringField(record, "title");
|
|
500
|
+
if (id !== undefined) context = { ...context, id };
|
|
501
|
+
if (identifier !== undefined) context = { ...context, identifier };
|
|
502
|
+
if (url !== undefined) context = { ...context, url };
|
|
503
|
+
if (title !== undefined) context = { ...context, title };
|
|
504
|
+
return Object.keys(context).length === 0 ? undefined : context;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function normalizePrompt(
|
|
508
|
+
record: Readonly<Record<string, unknown>> | undefined,
|
|
509
|
+
actor: LinearActor | undefined,
|
|
510
|
+
): LinearAgentPromptContext | undefined {
|
|
511
|
+
if (record === undefined) return undefined;
|
|
512
|
+
const body = stringField(record, "body") ?? stringField(record, "content") ?? stringField(record, "text");
|
|
513
|
+
const id = stringField(record, "id");
|
|
514
|
+
let prompt: LinearAgentPromptContext = {};
|
|
515
|
+
if (id !== undefined) prompt = { ...prompt, id };
|
|
516
|
+
if (body !== undefined) prompt = { ...prompt, body };
|
|
517
|
+
if (actor !== undefined) prompt = { ...prompt, actor };
|
|
518
|
+
return Object.keys(prompt).length === 0 ? undefined : prompt;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function normalizeActivity(
|
|
522
|
+
record: Readonly<Record<string, unknown>> | undefined,
|
|
523
|
+
): LinearAgentActivityContext | undefined {
|
|
524
|
+
if (record === undefined) return undefined;
|
|
525
|
+
const body = stringField(record, "body") ?? stringField(record, "content") ?? stringField(record, "text");
|
|
526
|
+
const id = stringField(record, "id");
|
|
527
|
+
const type = stringField(record, "type");
|
|
528
|
+
let activity: LinearAgentActivityContext = {};
|
|
529
|
+
if (id !== undefined) activity = { ...activity, id };
|
|
530
|
+
if (type !== undefined) activity = { ...activity, type };
|
|
531
|
+
if (body !== undefined) activity = { ...activity, body };
|
|
532
|
+
return Object.keys(activity).length === 0 ? undefined : activity;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function normalizeActor(record: Readonly<Record<string, unknown>> | undefined): LinearActor | undefined {
|
|
536
|
+
if (record === undefined) return undefined;
|
|
537
|
+
const id = stringField(record, "id");
|
|
538
|
+
const name = stringField(record, "name");
|
|
539
|
+
const email = stringField(record, "email");
|
|
540
|
+
const type = stringField(record, "type");
|
|
541
|
+
const isApp = booleanField(record, "isApp");
|
|
542
|
+
let actor: LinearActor = {};
|
|
543
|
+
if (id !== undefined) actor = { ...actor, id };
|
|
544
|
+
if (name !== undefined) actor = { ...actor, name };
|
|
545
|
+
if (email !== undefined) actor = { ...actor, email };
|
|
546
|
+
if (type !== undefined) actor = { ...actor, type };
|
|
547
|
+
if (isApp !== undefined) actor = { ...actor, isApp };
|
|
548
|
+
return Object.keys(actor).length === 0 ? undefined : actor;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function agentEventType(
|
|
552
|
+
action: string,
|
|
553
|
+
isStopSignal: boolean,
|
|
554
|
+
activity: LinearAgentActivityContext | undefined,
|
|
555
|
+
): LinearAgentEventType {
|
|
556
|
+
const lower = action.toLowerCase();
|
|
557
|
+
if (isStopSignal) return "stop";
|
|
558
|
+
if (lower.includes("created") || lower === "create") return "session_created";
|
|
559
|
+
if (lower.includes("prompt")) return "session_prompted";
|
|
560
|
+
if (activity !== undefined) return "activity";
|
|
561
|
+
return "session_prompted";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isStop(
|
|
565
|
+
action: string,
|
|
566
|
+
activity: LinearAgentActivityContext | undefined,
|
|
567
|
+
prompt: LinearAgentPromptContext | undefined,
|
|
568
|
+
data: Readonly<Record<string, unknown>>,
|
|
569
|
+
): boolean {
|
|
570
|
+
const lower = [
|
|
571
|
+
action,
|
|
572
|
+
activity?.type,
|
|
573
|
+
activity?.body,
|
|
574
|
+
prompt?.body,
|
|
575
|
+
stringField(data, "signal"),
|
|
576
|
+
]
|
|
577
|
+
.filter((item): item is string => item !== undefined)
|
|
578
|
+
.join(" ")
|
|
579
|
+
.toLowerCase();
|
|
580
|
+
return lower.includes("stop");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function labelsFromIssue(issue: Readonly<Record<string, unknown>>): readonly string[] {
|
|
584
|
+
const labelIds = arrayField(issue, "labelIds")
|
|
585
|
+
.map((item) => (typeof item === "string" ? item : undefined))
|
|
586
|
+
.filter((item): item is string => item !== undefined);
|
|
587
|
+
if (labelIds.length > 0) return labelIds;
|
|
588
|
+
|
|
589
|
+
const labels = recordField(issue, "labels");
|
|
590
|
+
const nodes = labels === undefined ? [] : arrayField(labels, "nodes");
|
|
591
|
+
return nodes
|
|
592
|
+
.map((item) => (typeof item === "object" && item !== null ? stringField(item as Record<string, unknown>, "id") : undefined))
|
|
593
|
+
.filter((item): item is string => item !== undefined);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function optionList(single: string | undefined, many: readonly string[] | undefined): readonly string[] | undefined {
|
|
597
|
+
if (many !== undefined) return many;
|
|
598
|
+
if (single !== undefined) return [single];
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function matchesOne(value: string | undefined, accepted: readonly string[] | undefined): boolean {
|
|
603
|
+
return accepted === undefined || (value !== undefined && accepted.includes(value));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function matchesLabels(labelIds: readonly string[], required: readonly string[] | undefined): boolean {
|
|
607
|
+
return required === undefined || required.some((labelId) => labelIds.includes(labelId));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function actorExcluded(
|
|
611
|
+
actor: LinearActor | undefined,
|
|
612
|
+
excluded: readonly string[] | undefined,
|
|
613
|
+
selfActorId: string | undefined,
|
|
614
|
+
): boolean {
|
|
615
|
+
const actorId = actor?.id;
|
|
616
|
+
if (actorId === undefined) return false;
|
|
617
|
+
if (selfActorId !== undefined && actorId === selfActorId) return true;
|
|
618
|
+
return excluded?.includes(actorId) ?? false;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function dedupeKey(
|
|
622
|
+
request: LinearWebhookRequest,
|
|
623
|
+
payload: Readonly<Record<string, unknown>>,
|
|
624
|
+
fallback: string,
|
|
625
|
+
): string {
|
|
626
|
+
return (
|
|
627
|
+
singleHeader(request.headers, "linear-delivery") ??
|
|
628
|
+
singleHeader(request.headers, "linear-event-id") ??
|
|
629
|
+
stringField(payload, "webhookId") ??
|
|
630
|
+
stringField(payload, "id") ??
|
|
631
|
+
`${fallback}:${singleHeader(request.headers, "linear-timestamp") ?? request.rawBody}`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function renderIssueComment(output: LinearIssueSinkOutput, prefix: string | undefined): string | undefined {
|
|
636
|
+
const pieces = [output.commentBody, output.finalSummary, output.prUrl === undefined ? undefined : `PR: ${output.prUrl}`]
|
|
637
|
+
.filter((item): item is string => item !== undefined && item.length > 0);
|
|
638
|
+
if (pieces.length === 0) return undefined;
|
|
639
|
+
return [prefix, pieces.join("\n\n")].filter((item): item is string => item !== undefined && item.length > 0).join("\n\n");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function externalUrlsForOutput(output: LinearAgentSinkOutput): readonly LinearExternalUrl[] {
|
|
643
|
+
const urls: LinearExternalUrl[] = [...(output.externalUrls ?? [])];
|
|
644
|
+
if (output.prUrl !== undefined && !urls.some((entry) => entry.url === output.prUrl)) {
|
|
645
|
+
urls.push({ label: "Pull request", url: output.prUrl });
|
|
646
|
+
}
|
|
647
|
+
return urls;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function parseTimestampMs(value: string): number | undefined {
|
|
651
|
+
const numeric = Number(value);
|
|
652
|
+
if (Number.isFinite(numeric)) {
|
|
653
|
+
return numeric < 10_000_000_000 ? numeric * 1000 : numeric;
|
|
654
|
+
}
|
|
655
|
+
const parsed = Date.parse(value);
|
|
656
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function normalizeSignature(value: string): string {
|
|
660
|
+
return value.startsWith("sha256=") ? value.slice("sha256=".length) : value;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function safeEqualHex(actual: string, expected: string): boolean {
|
|
664
|
+
if (!/^[a-f0-9]{64}$/i.test(actual) || !/^[a-f0-9]{64}$/i.test(expected)) return false;
|
|
665
|
+
const actualBuffer = Buffer.from(actual, "hex");
|
|
666
|
+
const expectedBuffer = Buffer.from(expected, "hex");
|
|
667
|
+
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function singleHeader(headers: LinearWebhookHeaders, name: string): string | undefined {
|
|
671
|
+
const lower = name.toLowerCase();
|
|
672
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
673
|
+
if (key.toLowerCase() === lower) {
|
|
674
|
+
if (typeof value === "string") return value;
|
|
675
|
+
if (Array.isArray(value)) return value.find((item): item is string => typeof item === "string");
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function asRecord(value: unknown): Readonly<Record<string, unknown>> {
|
|
683
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function recordField(record: Readonly<Record<string, unknown>> | undefined, key: string): Readonly<Record<string, unknown>> | undefined {
|
|
687
|
+
if (record === undefined) return undefined;
|
|
688
|
+
const value = record[key];
|
|
689
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function arrayField(record: Readonly<Record<string, unknown>>, key: string): readonly unknown[] {
|
|
693
|
+
const value = record[key];
|
|
694
|
+
return Array.isArray(value) ? value : [];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function stringField(record: Readonly<Record<string, unknown>> | undefined, key: string): string | undefined {
|
|
698
|
+
if (record === undefined) return undefined;
|
|
699
|
+
const value = record[key];
|
|
700
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function booleanField(record: Readonly<Record<string, unknown>>, key: string): boolean | undefined {
|
|
704
|
+
const value = record[key];
|
|
705
|
+
return typeof value === "boolean" ? value : undefined;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function linearSourceFailed(message: string): RuntimeError {
|
|
709
|
+
return ioFailed("source", "linear", message);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function linearSinkFailed(message: string): RuntimeError {
|
|
713
|
+
return ioFailed("sink", "linear", message);
|
|
714
|
+
}
|