From 566d552ef14c44a5f7df4d17a393dc352fe6bf7f Mon Sep 17 00:00:00 2001 From: joaobmonteiro Date: Sat, 11 Apr 2026 17:20:03 +0200 Subject: [PATCH 1/3] feat: add Linear webhook integration for auto-triggering workflows When a Linear issue assigned to a configurable user (default: "archon") is moved to "In Progress", the adapter automatically triggers a configured workflow (default: "implement") with the issue context. Team-to-codebase mappings are configured in ~/.archon/config.yaml under linear.mappings. New files: - packages/adapters/src/pm/linear/ (adapter, auth, types, tests) Modified: - Config types: LinearConfig on GlobalConfig - Server: adapter init + POST /webhooks/linear endpoint - .env.example: LINEAR_API_KEY, LINEAR_WEBHOOK_SECRET Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 9 + bun.lock | 53 +- packages/adapters/package.json | 4 +- packages/adapters/src/index.ts | 3 + .../adapters/src/pm/linear/adapter.test.ts | 518 ++++++++++++++++++ packages/adapters/src/pm/linear/adapter.ts | 334 +++++++++++ packages/adapters/src/pm/linear/auth.ts | 34 ++ packages/adapters/src/pm/linear/index.ts | 1 + packages/adapters/src/pm/linear/types.ts | 70 +++ packages/core/src/config/config-types.ts | 20 + packages/core/src/index.ts | 1 + packages/server/src/index.ts | 56 +- 12 files changed, 1079 insertions(+), 24 deletions(-) create mode 100644 packages/adapters/src/pm/linear/adapter.test.ts create mode 100644 packages/adapters/src/pm/linear/adapter.ts create mode 100644 packages/adapters/src/pm/linear/auth.ts create mode 100644 packages/adapters/src/pm/linear/index.ts create mode 100644 packages/adapters/src/pm/linear/types.ts diff --git a/.env.example b/.env.example index 125ad43e98..973a82a9c8 100644 --- a/.env.example +++ b/.env.example @@ -151,6 +151,15 @@ GITEA_ALLOWED_USERS= # If not set, falls back to BOT_DISPLAY_NAME then config.botName # GITEA_BOT_MENTION=archon +# ============================================ +# Linear (PM Adapter) +# ============================================ +# Triggers workflows when Linear issues assigned to the configured user +# transition to "In Progress". Configure team→codebase mappings in +# ~/.archon/config.yaml under linear.mappings. +# LINEAR_API_KEY=lin_api_... +# LINEAR_WEBHOOK_SECRET= + # Server # PORT=3090 # Default: 3090. Uncomment to override — must match between server and Vite proxy. # HOST=0.0.0.0 # Bind address (default: 0.0.0.0). Set to 127.0.0.1 to restrict to localhost only. diff --git a/bun.lock b/bun.lock index 1c6cf3891f..47b1b68540 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", + "@linear/sdk": "^34.0.0", "@octokit/rest": "^22.0.0", "@slack/bolt": "^4.6.0", "discord.js": "^14.16.0", @@ -288,25 +289,25 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1031.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.0", "@aws-sdk/credential-provider-node": "^3.972.31", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.30", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/token-providers": "3.1031.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.16", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ZgiSo2wslPXlv7wK4m2ULu2VfimbVRRlho0DqXhlvZGEqvtC209cMOxfPZWJ79Fz9sf0IzmWFkDtvMYjnwyLfw=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/token-providers": "3.1032.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSRz/48As9c3DeS+9ZWd7kk9171pJntCCuehHBDeprD9CPF+C+ATaVNJ5SOLE5RIBR2IHOVTwjAgJt/nkS/6Yg=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.18", "@smithy/core": "^3.23.15", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-8j+dMtyDqNXFmi09CBdz8TY6Ltf2jhfHuP6ZvG4zVjndRc6JF0aeBUbRwQLndbptFCsdctRQgdNWecy4TIfXAw=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.1", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.18", "@smithy/core": "^3.23.15", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-gy/gffKz0zaHDaqRiLCdIvgHmaAL/HXuAtMcBP7euYSFx4BsbsdlfmUBJag+Gqe62z6/XuloKyQyaiH+kS3Vrg=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.26", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WBHAMxyPdgeJY6ZGLvq9mJwzZ+GaNUROQbfdVshtMsDVBrZTj5ZuFjKclSjSHvKSHJ4Y4O2yvI/aA/hrJbYfng=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-xfUt2CUZDC+Tf16A6roD1b4pk/nrXdkoLY3TEhv198AXDtBo5xUJP1zd0e8SmuKLN4PpIBX96OizZbmMlcI6oQ=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.5.3", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.23", "tslib": "^2.6.2" } }, "sha512-+1DwCjjpo1WoiZTN08yGitI3nUwZUSQWVWFrW4C46HqZwACjcUQ7C66tnKPBTVxrEYYDOP11A6Afmu1L6ylt3g=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.5.3", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.23", "tslib": "^2.6.2" } }, "sha512-hjNeYb6oLyHgMihra83ie0J/T2y9om3cy1qC90h9DRgvYXEoN4BCFf8bHguZjKhXunnv7YkmZRuYL5Mkk77eCA=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/credential-provider-env": "^3.972.26", "@aws-sdk/credential-provider-http": "^3.972.28", "@aws-sdk/credential-provider-login": "^3.972.30", "@aws-sdk/credential-provider-process": "^3.972.26", "@aws-sdk/credential-provider-sso": "^3.972.30", "@aws-sdk/credential-provider-web-identity": "^3.972.30", "@aws-sdk/nested-clients": "^3.996.20", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Fg1oJcoijwOZjTxdbx+ubqbQl8YEQ4Cwhjw6TWzQjuDEvQYNhnCXW2pN7eKtdTrdE4a6+5TVKGSm2I+i2BKIQg=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-env": "^3.972.27", "@aws-sdk/credential-provider-http": "^3.972.29", "@aws-sdk/credential-provider-login": "^3.972.31", "@aws-sdk/credential-provider-process": "^3.972.27", "@aws-sdk/credential-provider-sso": "^3.972.31", "@aws-sdk/credential-provider-web-identity": "^3.972.31", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-PuQ7e8WYzAPpzvFcajxf8c0LqSzakVHVlKw8M0oubk8Kf347YOCCqT1seQrHs5AdZuIh2RD9LX4O+Xa5ImEBfQ=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/nested-clients": "^3.996.20", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-nchIrrI/7dgjG1bW/DEWOJc00K9n+kkl6B8Mk0KO6d4GfWBOXlVr9uHp7CJR9FIrjmov5SGjHXG2q9XAtkRw6Q=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-bBmWDmtSpmLOZR6a0kmowBcVL1hiL8Vlap/RXeMpFd7JbWl87YcwqL6T9LH/0oBVEZXu1dUZAtojgSuZgMO5xw=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.31", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.26", "@aws-sdk/credential-provider-http": "^3.972.28", "@aws-sdk/credential-provider-ini": "^3.972.30", "@aws-sdk/credential-provider-process": "^3.972.26", "@aws-sdk/credential-provider-sso": "^3.972.30", "@aws-sdk/credential-provider-web-identity": "^3.972.30", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-99OHVQ6eZ5DTxiOWgHdjBMvLqv7xoY4jLK6nZ1NcNSQbAnYZkQNIHi/VqInc9fnmg7of9si/z+waE6YL9OQIlw=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.32", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.27", "@aws-sdk/credential-provider-http": "^3.972.29", "@aws-sdk/credential-provider-ini": "^3.972.31", "@aws-sdk/credential-provider-process": "^3.972.27", "@aws-sdk/credential-provider-sso": "^3.972.31", "@aws-sdk/credential-provider-web-identity": "^3.972.31", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-9aj0x9hGYUondBZSD0XkksAdHhOKttFw4BWpLCeggeg40qSJxGrAP++g0GCm0VqWc1WtC/NRFiAVzPCy56vmog=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.26", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-jibxNld3m+vbmQwn98hcQ+fLIVrx3cQuhZlSs1/hix48SjDS5/pjMLwpmtLD/lFnd6ve1AL4o1bZg3X1WRa2SQ=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-1CZvfb1WzudWWIFAVQkd1OI/T1RxPcSvNWzNsb2BMBVsBJzBtB8dV5f2nymHVU4UqwxipdVt/DAbgdDRf33JDg=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/nested-clients": "^3.996.20", "@aws-sdk/token-providers": "3.1031.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-honYIM17F/+QSWJRE84T4u//ofqEi7rLbnwmIpu7fgFX5PML78wbtdSAy5Xwyve3TLpE9/f9zQx0aBVxSjAOPw=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/token-providers": "3.1032.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-x8Mx18S48XMl9bEEpYwmXDTvjWGPIfDadReN37Lc099/DUrlL4Zs9T9rwwggo6DkKS1aev6v+MTUx7JTa87TZQ=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/nested-clients": "^3.996.20", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CyL4oWUlONQRN2SsYMVrA9Z3i3QfLWTQctI8tuKbjNGCVVDCnJf/yMbSJCOZgpPFRtxh7dgQwvpqwmJm+iytmw=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-zfuNMIkGfjYsHis9qytYf74Bcmq6Ji9Xwf4w53baRCI/b2otTwZv3SW1uRiJ5Di7999QzRGhHZ96+eUeo3gSOA=="], "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg=="], @@ -318,15 +319,15 @@ "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@smithy/core": "^3.23.15", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-lCz6JfelhjD6Eco1urXM2rOYRaxROSqeoY6IEKx+soegFJOajmIBCMHTAWuJl25Wf9IAST+i0/yOk9G3rMV26A=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@smithy/core": "^3.23.15", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-L+hXN2HDomlIsWSHW5DVD7ppccCeRnlHXZ5uHG34ePTjF5bm0I1fmrJLbUGiW97xRXWryit5cjdP4Sx2FwiGog=="], "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/eventstream-codec": "^4.2.14", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.20", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.0", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.30", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.16", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-bzPdsNQnCh6TvvUmTHLZlL8qgyME6mNiUErcRMyJPywIl1BEu2VZRShel3mUoSh89bOBEXEWtjocDMolFxd/9A=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.21", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Me3d/ua2lb2G0bQfFmvCeQQp3+nN6GSPqMxDmi/IQlQ8CrlpQ5C0JJHpz2AnOUkEFI0lBNrAL3Vnt29l44ndkA=="], "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.16", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1031.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.0", "@aws-sdk/nested-clients": "^3.996.20", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-zj/PvnbQK/2KJNln5K2QRI9HSsy+B4emz2gbQyUHkk6l7Lidu83P/9tfmC2cJXkcC3vdmyKH2DP3Iw/FDfKQuQ=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-n+PU8Z+gll7p3wDrH+Wo6fkt8sPrVnq30YYM6Ryga95oJlEneNMEbDHj0iqjMX3V7gaGdJo/hJWyPo4lscP+mA=="], "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], @@ -338,7 +339,7 @@ "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.16", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.30", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-ccvu0FNCI0C6OqmxI/tWn7BD8qGooWuURssiIM+6vbksFO8opXR4JOGtGYPj8QYzN/vfwNYrcK344PPbYuvzRg=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.17", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-utF5qjjbuJQuU9VdCkWl7L87sr93cApsrD+uxGfUnlafX8iyEzJrb7EZnufjThURZVTOtelRMXrblWxpefElUg=="], "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.18", "", { "dependencies": { "@smithy/types": "^4.14.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA=="], @@ -528,6 +529,8 @@ "@grammyjs/types": ["@grammyjs/types@3.26.0", "", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="], + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/zod-openapi": ["@hono/zod-openapi@0.19.10", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@hono/zod-validator": "^0.7.1", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": ">=3.0.0" } }, "sha512-dpoS6DenvoJyvxtQ7Kd633FRZ/Qf74+4+o9s+zZI8pEqnbjdF/DtxIib08WDpCaWabMEJOL5TXpMgNEZvb7hpA=="], @@ -612,6 +615,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@linear/sdk": ["@linear/sdk@34.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-mTPn7Vb9vHQUklymyUwnmvhfw3nQRtZy2sWhgD+ANS9plGUSw5GArxdwchevKvsk5nSE7FgyvHwhOVPlvEX8ow=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.2", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.2", "@mariozechner/clipboard-darwin-universal": "0.3.2", "@mariozechner/clipboard-darwin-x64": "0.3.2", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", "@mariozechner/clipboard-linux-x64-musl": "0.3.2", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" } }, "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA=="], "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw=="], @@ -636,17 +641,17 @@ "@mariozechner/jiti": ["@mariozechner/jiti@2.6.5", "", { "dependencies": { "std-env": "^3.10.0", "yoctocolors": "^2.1.2" }, "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw=="], - "@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@0.67.5", "", { "dependencies": { "@mariozechner/pi-ai": "^0.67.5" } }, "sha512-XZwAVYEja4YV3Or+Fb1fMvi/KphpaEvMcfGe1/lBNEOllDK3m6J/6MdqLJy85rettX3uKRuGjF3adDNju+LRow=="], + "@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@0.67.68", "", { "dependencies": { "@mariozechner/pi-ai": "^0.67.68" } }, "sha512-anwFuzeUL7Qjbyih4DWY7w1zrOTrBxaz1L6+duLUuuzpHOun0EiP4KWIGTXPT5oJA7ZaeRNTyXJ7PlWfGQG33g=="], - "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.67.5", "", { "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.14.1", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.26.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-TgxI2seq+gIRy6oRQA/ogyj8c9vESMQEeICPKYe29hJCLkN/i7tgKnU9jIM+rcAJmtGaO4Iy0IL7wYV4g0qjsw=="], + "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.67.68", "", { "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "^2.2.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.26.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-DWWQmcb3IV3mbGXmzYBScfKA6kA52n/stY029eiBikrIxVT7DGLG6n7KSvTA2R4qBSgi1iFL3nGHtwxmtIn6Lg=="], - "@mariozechner/pi-coding-agent": ["@mariozechner/pi-coding-agent@0.67.5", "", { "dependencies": { "@mariozechner/jiti": "^2.6.2", "@mariozechner/pi-agent-core": "^0.67.5", "@mariozechner/pi-ai": "^0.67.5", "@mariozechner/pi-tui": "^0.67.5", "@silvia-odwyer/photon-node": "^0.3.4", "ajv": "^8.17.1", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "strip-ansi": "^7.1.0", "undici": "^7.19.1", "uuid": "^11.1.0", "yaml": "^2.8.2" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2" }, "bin": { "pi": "dist/cli.js" } }, "sha512-U/kZ173IDmkwq7p8zKsrhb5fpxWUW53NTKXva6fyzwx3o/tGl3PzdnyxBfv7vHz15S7mgL/dpdiF/ANUV34JTw=="], + "@mariozechner/pi-coding-agent": ["@mariozechner/pi-coding-agent@0.67.68", "", { "dependencies": { "@mariozechner/jiti": "^2.6.2", "@mariozechner/pi-agent-core": "^0.67.68", "@mariozechner/pi-ai": "^0.67.68", "@mariozechner/pi-tui": "^0.67.68", "@silvia-odwyer/photon-node": "^0.3.4", "ajv": "^8.17.1", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "strip-ansi": "^7.1.0", "undici": "^7.19.1", "uuid": "^11.1.0", "yaml": "^2.8.2" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2" }, "bin": { "pi": "dist/cli.js" } }, "sha512-Bai2yUBpgjftqGvg3GNV9pxcMAatqJwQYsqM+7j39+tm+IpoW7hbMBnzXZQvykAwXIrTXpZFF5yp5ajeDI5Atg=="], - "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.67.5", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-e1dUhXDr2LUUkHmuVYxPubQnk3NYcZLNOinUVTYXCSTAEzgSq0vH5LMgf5/zHspi5AmncmJmc85Qf/VFmnpw7Q=="], + "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.67.68", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-RhcMaGz88lNOm5+9yx+YCIfXZALLbMxB2cwsoHzyOzs+OZAItw8tz6xJZPAnX0RHY+ENQEGMMDY9TF6pxxnkbA=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - "@mistralai/mistralai": ["@mistralai/mistralai@1.14.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.0", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-JQUGIXjFWnw/J9LpTSf/ZXwVW3Sh8FBAcfTo5QvAHqkl4CfSiIwnjRJhMoAFcP6ncCe84YPU1ncDGX+p3OXnfg=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], @@ -1622,7 +1627,7 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], "fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], @@ -1716,7 +1721,7 @@ "grammy": ["grammy@1.42.0", "", { "dependencies": { "@grammyjs/types": "3.26.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g=="], - "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], + "graphql": ["graphql@15.10.2", "", {}, "sha512-1PRqdDPAmViWr4h1GVBT8RoPZfWSGZa7kDzleTilOfVIslsgf+cia3Nl95v1KDmR4iERPaT7WzQ+tN4MJmbg3w=="], "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], @@ -1872,6 +1877,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-unfetch": ["isomorphic-unfetch@3.1.0", "", { "dependencies": { "node-fetch": "^2.6.1", "unfetch": "^4.2.0" } }, "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], @@ -2660,6 +2667,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unified": ["unified@9.2.2", "", { "dependencies": { "bail": "^1.0.0", "extend": "^3.0.0", "is-buffer": "^2.0.0", "is-plain-obj": "^2.0.0", "trough": "^1.0.0", "vfile": "^4.0.0" } }, "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ=="], @@ -2994,6 +3003,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "msw/graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], + "msw/path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "msw/type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 0e2fb23d52..f2a4fac1cf 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -7,11 +7,12 @@ "exports": { ".": "./src/index.ts", "./*": "./src/*", + "./pm/linear": "./src/pm/linear/index.ts", "./community/forge/gitea": "./src/community/forge/gitea/index.ts", "./community/forge/gitlab": "./src/community/forge/gitlab/index.ts" }, "scripts": { - "test": "bun test src/chat/ src/community/chat/ src/community/forge/gitlab/auth.test.ts src/forge/github/auth.test.ts src/utils/ && bun test src/forge/github/adapter.test.ts && bun test src/forge/github/context.test.ts && bun test src/community/forge/gitea/adapter.test.ts && bun test src/community/forge/gitlab/adapter.test.ts", + "test": "bun test src/chat/ src/community/chat/ src/community/forge/gitlab/auth.test.ts src/forge/github/auth.test.ts src/utils/ && bun test src/forge/github/adapter.test.ts && bun test src/forge/github/context.test.ts && bun test src/community/forge/gitea/adapter.test.ts && bun test src/community/forge/gitlab/adapter.test.ts && bun test src/pm/linear/adapter.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { @@ -19,6 +20,7 @@ "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", + "@linear/sdk": "^34.0.0", "@octokit/rest": "^22.0.0", "@slack/bolt": "^4.6.0", "discord.js": "^14.16.0", diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index c724683334..08f310bcb1 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -5,5 +5,8 @@ export { SlackAdapter } from './chat/slack'; // Forge adapters export { GitHubAdapter } from './forge/github'; +// PM adapters +export { LinearAdapter } from './pm/linear'; + // Community adapters export { DiscordAdapter } from './community/chat/discord'; diff --git a/packages/adapters/src/pm/linear/adapter.test.ts b/packages/adapters/src/pm/linear/adapter.test.ts new file mode 100644 index 0000000000..f447fa25f1 --- /dev/null +++ b/packages/adapters/src/pm/linear/adapter.test.ts @@ -0,0 +1,518 @@ +/** + * Unit tests for Linear adapter + * + * Runs in its own bun test batch to avoid mock.module() pollution. + */ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { createHmac } from 'crypto'; + +// Mock logger before importing anything that uses it +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), + child: mock(function (this: unknown) { + return this; + }), + bindings: mock(() => ({ module: 'test' })), + isLevelEnabled: mock(() => true), + level: 'info', +}; +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + getArchonHome: mock(() => '/tmp/archon-test'), +})); + +// Mock database modules +const mockGetOrCreateConversation = mock(async () => ({ + id: 'conv-linear-test', + platform_type: 'linear', + platform_conversation_id: 'linear/ENG/ENG-123', + codebase_id: null, + cwd: null, + isolation_env_id: null, + ai_assistant_type: 'claude', + title: null, + hidden: false, + deleted_at: null, + last_activity_at: null, + created_at: new Date(), + updated_at: new Date(), +})); +const mockUpdateConversation = mock(async () => {}); + +mock.module('@archon/core/db/conversations', () => ({ + getOrCreateConversation: mockGetOrCreateConversation, + updateConversation: mockUpdateConversation, +})); + +const mockFindCodebaseByName = mock(async () => ({ + id: 'codebase-test', + name: 'my-org/my-repo', + repository_url: 'https://github.com/my-org/my-repo', + default_cwd: '/tmp/test-repo', + ai_assistant_type: 'claude', + allow_env_keys: false, + commands: {}, + created_at: new Date(), + updated_at: new Date(), +})); + +mock.module('@archon/core/db/codebases', () => ({ + findCodebaseByName: mockFindCodebaseByName, +})); + +// Mock workflow execution dependencies +const mockExecuteWorkflow = mock(async () => ({ + success: true as const, + workflowRunId: 'run-123', + summary: 'Workflow completed successfully.', +})); + +mock.module('@archon/workflows/executor', () => ({ + executeWorkflow: mockExecuteWorkflow, +})); + +const mockResolveWorkflowName = mock((_name: string) => ({ + name: 'implement', + description: 'Implement changes', + nodes: [], +})); + +mock.module('@archon/workflows/router', () => ({ + resolveWorkflowName: mockResolveWorkflowName, +})); + +const mockDiscoverWorkflowsWithConfig = mock(async () => ({ + workflows: [ + { + workflow: { name: 'implement', description: 'Implement changes', nodes: [] }, + source: 'bundled' as const, + }, + ], + errors: [], +})); + +mock.module('@archon/workflows/workflow-discovery', () => ({ + discoverWorkflowsWithConfig: mockDiscoverWorkflowsWithConfig, +})); + +// Mock isolation resolution +const mockValidateAndResolveIsolation = mock(async () => ({ + cwd: '/tmp/test-worktree', +})); + +mock.module('@archon/core/orchestrator', () => ({ + validateAndResolveIsolation: mockValidateAndResolveIsolation, +})); + +// Mock workflow deps +mock.module('@archon/core/workflows/store-adapter', () => ({ + createWorkflowDeps: mock(() => ({ + store: {}, + getAssistantClient: mock(), + loadConfig: mock(), + })), +})); + +// Mock config loader +mock.module('@archon/core', () => ({ + loadConfig: mock(async () => ({})), +})); + +// Mock @linear/sdk +const mockCreateComment = mock(async () => ({ success: true })); +const MockLinearClient = mock(() => ({ + createComment: mockCreateComment, +})); + +mock.module('@linear/sdk', () => ({ + LinearClient: MockLinearClient, +})); + +// Mock @archon/isolation +mock.module('@archon/isolation', () => ({ + IsolationBlockedError: class IsolationBlockedError extends Error { + reason: string; + constructor(message: string, reason: string) { + super(message); + this.name = 'IsolationBlockedError'; + this.reason = reason; + } + }, +})); + +// Now import the adapter under test +import { LinearAdapter } from './adapter'; +import { ConversationLockManager } from '@archon/core'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +const WEBHOOK_SECRET = 'test-webhook-secret'; + +function signPayload(payload: string, secret = WEBHOOK_SECRET): string { + const hmac = createHmac('sha256', secret); + return hmac.update(payload).digest('hex'); +} + +function buildIssuePayload( + overrides?: Partial<{ + action: string; + type: string; + stateType: string; + assigneeName: string; + teamKey: string; + identifier: string; + updatedFrom: Record | undefined; + }> +): string { + const defaults = { + action: 'update', + type: 'Issue', + stateType: 'started', + assigneeName: 'archon', + teamKey: 'ENG', + identifier: 'ENG-123', + updatedFrom: { stateId: 'old-state-id' }, + }; + const opts = { ...defaults, ...overrides }; + + return JSON.stringify({ + action: opts.action, + type: opts.type, + data: { + id: 'issue-uuid-123', + identifier: opts.identifier, + title: 'Implement user authentication', + description: 'Add JWT-based auth to the API.', + priority: 1, + state: { id: 'state-1', name: 'In Progress', type: opts.stateType }, + assignee: opts.assigneeName + ? { id: 'user-1', name: opts.assigneeName, displayName: opts.assigneeName } + : undefined, + team: { id: 'team-1', key: opts.teamKey, name: 'Engineering' }, + labels: [{ id: 'label-1', name: 'backend' }], + url: `https://linear.app/my-org/issue/${opts.identifier}`, + }, + updatedFrom: opts.updatedFrom, + createdAt: new Date().toISOString(), + organizationId: 'org-1', + }); +} + +// Create a mock lock manager that immediately executes handlers +const mockLockManager = { + acquireLock: mock(async (_id: string, handler: () => Promise) => { + await handler(); + return { status: 'started' }; + }), + getStats: () => ({ + active: 0, + queuedTotal: 0, + queuedByConversation: [], + maxConcurrent: 10, + activeConversationIds: [], + }), +} as unknown as ConversationLockManager; + +function createAdapter( + teamMappings: Record = { ENG: 'my-org/my-repo' } +): LinearAdapter { + return new LinearAdapter( + 'fake-api-key', + WEBHOOK_SECRET, + mockLockManager, + 'archon', + 'implement', + new Map(Object.entries(teamMappings)) + ); +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +describe('LinearAdapter', () => { + beforeEach(() => { + mockLogger.error.mockClear(); + mockLogger.warn.mockClear(); + mockLogger.info.mockClear(); + mockCreateComment.mockClear(); + mockGetOrCreateConversation.mockClear(); + mockUpdateConversation.mockClear(); + mockFindCodebaseByName.mockClear(); + mockExecuteWorkflow.mockClear(); + mockResolveWorkflowName.mockClear(); + mockDiscoverWorkflowsWithConfig.mockClear(); + mockValidateAndResolveIsolation.mockClear(); + (mockLockManager.acquireLock as ReturnType).mockClear(); + }); + + describe('platform interface', () => { + test('returns batch streaming mode', () => { + const adapter = createAdapter(); + expect(adapter.getStreamingMode()).toBe('batch'); + }); + + test('returns linear platform type', () => { + const adapter = createAdapter(); + expect(adapter.getPlatformType()).toBe('linear'); + }); + + test('ensureThread returns same conversation ID', async () => { + const adapter = createAdapter(); + const id = await adapter.ensureThread('linear/ENG/ENG-123'); + expect(id).toBe('linear/ENG/ENG-123'); + }); + }); + + describe('signature verification', () => { + test('rejects invalid signature silently', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload(); + await adapter.handleWebhook(payload, 'invalid-signature'); + + expect(mockLogger.error).toHaveBeenCalledWith('linear.signature_verification_failed'); + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + + test('accepts valid signature', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + // Should proceed past signature verification + expect(mockLogger.error).not.toHaveBeenCalledWith('linear.signature_verification_failed'); + }); + }); + + describe('event filtering', () => { + test('ignores non-update actions', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload({ action: 'create' }); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + + test('ignores non-Issue types', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload({ type: 'Comment' }); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + + test('ignores updates without state change', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload({ updatedFrom: { title: 'Old title' } }); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + + test('ignores state changes that are not to "started"', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload({ stateType: 'completed' }); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + + test('ignores issues not assigned to target user', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload({ assigneeName: 'someone-else' }); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + + test('assignee matching is case-insensitive', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload({ assigneeName: 'Archon' }); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + // Should proceed to workflow execution + expect(mockExecuteWorkflow).toHaveBeenCalled(); + }); + }); + + describe('codebase resolution', () => { + test('logs warning and posts comment when no team mapping exists', async () => { + const adapter = createAdapter({ PLATFORM: 'other-repo' }); + const payload = buildIssuePayload({ teamKey: 'ENG' }); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockCreateComment).toHaveBeenCalled(); + const commentBody = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; + expect(commentBody.body).toContain('No codebase mapping found'); + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + + test('logs error and posts comment when codebase not found in DB', async () => { + mockFindCodebaseByName.mockResolvedValueOnce(null); + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockCreateComment).toHaveBeenCalled(); + const commentBody = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; + expect(commentBody.body).toContain('not found'); + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + }); + + describe('workflow resolution', () => { + test('logs error when workflow not found', async () => { + mockResolveWorkflowName.mockReturnValueOnce( + undefined as unknown as ReturnType + ); + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockCreateComment).toHaveBeenCalled(); + const commentBody = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; + expect(commentBody.body).toContain('not found'); + expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + }); + }); + + describe('workflow execution', () => { + test('executes workflow on valid trigger', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + expect(mockGetOrCreateConversation).toHaveBeenCalledWith('linear', 'linear/ENG/ENG-123'); + expect(mockUpdateConversation).toHaveBeenCalled(); + expect(mockExecuteWorkflow).toHaveBeenCalledTimes(1); + }); + + test('posts start notification before execution', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + // First comment: start notification, second: summary + expect(mockCreateComment).toHaveBeenCalled(); + const firstCall = mockCreateComment.mock.calls[0]?.[0] as { body: string }; + expect(firstCall.body).toContain('Starting workflow'); + }); + + test('posts summary on successful completion', async () => { + mockExecuteWorkflow.mockResolvedValueOnce({ + success: true as const, + workflowRunId: 'run-456', + summary: 'All tasks completed.', + }); + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + // Last comment should be the summary + const lastCall = mockCreateComment.mock.calls[ + mockCreateComment.mock.calls.length - 1 + ]?.[0] as { body: string }; + expect(lastCall.body).toContain('All tasks completed.'); + }); + + test('posts error on workflow failure', async () => { + mockExecuteWorkflow.mockResolvedValueOnce({ + success: false as const, + error: 'Node "build" failed', + }); + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + const lastCall = mockCreateComment.mock.calls[ + mockCreateComment.mock.calls.length - 1 + ]?.[0] as { body: string }; + expect(lastCall.body).toContain('failed'); + expect(lastCall.body).toContain('Node "build" failed'); + }); + }); + + describe('context message', () => { + test('builds context with issue details', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + // The context message is the 6th argument to executeWorkflow + const callArgs = mockExecuteWorkflow.mock.calls[0] as unknown[]; + const contextMessage = callArgs[5] as string; + expect(contextMessage).toContain('ENG-123'); + expect(contextMessage).toContain('Implement user authentication'); + expect(contextMessage).toContain('Urgent'); + expect(contextMessage).toContain('backend'); + expect(contextMessage).toContain('JWT-based auth'); + }); + }); + + describe('sendMessage', () => { + test('posts comment with bot marker', async () => { + const adapter = createAdapter(); + // Trigger a webhook to populate issueIdMap + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + mockCreateComment.mockClear(); + await adapter.sendMessage('linear/ENG/ENG-123', 'Test message'); + + expect(mockCreateComment).toHaveBeenCalledTimes(1); + const call = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; + expect(call.issueId).toBe('issue-uuid-123'); + expect(call.body).toContain(''); + expect(call.body).toContain('Test message'); + }); + + test('warns when issue ID not found for conversation', async () => { + const adapter = createAdapter(); + await adapter.sendMessage('linear/UNKNOWN/UNKNOWN-1', 'Test'); + + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockCreateComment).not.toHaveBeenCalled(); + }); + }); + + describe('isolation', () => { + test('passes correct isolation hints', async () => { + const adapter = createAdapter(); + const payload = buildIssuePayload(); + const signature = signPayload(payload); + await adapter.handleWebhook(payload, signature); + + const call = mockValidateAndResolveIsolation.mock.calls[0] as unknown[]; + const hints = call[4] as IsolationHints; + expect(hints).toEqual({ + workflowType: 'issue', + workflowId: 'ENG-123', + }); + }); + }); +}); + +// Type import for test assertions +import type { IsolationHints } from '@archon/isolation'; diff --git a/packages/adapters/src/pm/linear/adapter.ts b/packages/adapters/src/pm/linear/adapter.ts new file mode 100644 index 0000000000..9525c1e7f9 --- /dev/null +++ b/packages/adapters/src/pm/linear/adapter.ts @@ -0,0 +1,334 @@ +/** + * Linear platform adapter — triggers workflows when issues move to "In Progress". + * Webhook-driven (no polling), follows the forge adapter pattern (GitHub, Gitea). + */ +import { LinearClient } from '@linear/sdk'; +import type { IPlatformAdapter, MessageMetadata, Conversation } from '@archon/core'; +import type { IsolationHints } from '@archon/isolation'; +import { IsolationBlockedError } from '@archon/isolation'; +import { ConversationLockManager } from '@archon/core'; +import { createWorkflowDeps } from '@archon/core/workflows/store-adapter'; +import { validateAndResolveIsolation } from '@archon/core/orchestrator'; +import * as db from '@archon/core/db/conversations'; +import * as codebaseDb from '@archon/core/db/codebases'; +import { loadConfig } from '@archon/core'; +import { executeWorkflow } from '@archon/workflows/executor'; +import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; +import { resolveWorkflowName } from '@archon/workflows/router'; +import { getArchonHome } from '@archon/paths'; +import { createLogger } from '@archon/paths'; +import { verifyLinearSignature, isTargetAssignee } from './auth'; +import { splitIntoParagraphChunks } from '../../utils/message-splitting'; +import type { LinearWebhookPayload, LinearIssueData } from './types'; +import { isIssueData } from './types'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('adapter.linear'); + return cachedLog; +} + +/** Max comment length (Linear has no strict limit, but keep comments reasonable) */ +const MAX_LENGTH = 65000; + +/** Hidden marker added to bot comments to prevent self-triggering loops */ +const BOT_RESPONSE_MARKER = ''; + +/** Map Linear priority numbers to human-readable labels */ +const PRIORITY_LABELS: Record = { + 0: 'No priority', + 1: 'Urgent', + 2: 'High', + 3: 'Medium', + 4: 'Low', +}; + +export class LinearAdapter implements IPlatformAdapter { + private readonly linearClient: LinearClient; + private readonly webhookSecret: string; + private readonly lockManager: ConversationLockManager; + private readonly targetAssignee: string; + private readonly defaultWorkflow: string; + private readonly teamCodebaseMap: Map; + /** Maps conversationId → Linear issue ID (UUID) for comment posting */ + private readonly issueIdMap = new Map(); + + constructor( + apiKey: string, + webhookSecret: string, + lockManager: ConversationLockManager, + targetAssignee = 'archon', + defaultWorkflow = 'implement', + teamCodebaseMap: Map = new Map() + ) { + this.linearClient = new LinearClient({ apiKey }); + this.webhookSecret = webhookSecret; + this.lockManager = lockManager; + this.targetAssignee = targetAssignee; + this.defaultWorkflow = defaultWorkflow; + this.teamCodebaseMap = teamCodebaseMap; + } + + // ─── IPlatformAdapter ─────────────────────────────────────────────────────── + + async sendMessage( + conversationId: string, + message: string, + _metadata?: MessageMetadata + ): Promise { + const issueId = this.issueIdMap.get(conversationId); + if (!issueId) { + getLog().warn({ conversationId }, 'linear.send_message_no_issue_id'); + return; + } + + const chunks = splitIntoParagraphChunks(message, MAX_LENGTH); + for (const chunk of chunks) { + const body = `${BOT_RESPONSE_MARKER}\n${chunk}`; + try { + await this.linearClient.createComment({ issueId, body }); + } catch (error) { + getLog().error( + { err: error as Error, issueId, conversationId }, + 'linear.comment_create_failed' + ); + } + } + } + + async ensureThread(originalConversationId: string, _messageContext?: unknown): Promise { + // Linear issues are inherently threaded — no-op + return originalConversationId; + } + + getStreamingMode(): 'stream' | 'batch' { + return 'batch'; + } + + getPlatformType(): string { + return 'linear'; + } + + async start(): Promise { + getLog().info('linear_adapter_started'); + } + + stop(): void { + getLog().info('linear_adapter_stopped'); + } + + // ─── Webhook Processing ───────────────────────────────────────────────────── + + async handleWebhook(payload: string, signature: string): Promise { + // 1. Verify signature + if (!verifyLinearSignature(payload, signature, this.webhookSecret)) { + getLog().error('linear.signature_verification_failed'); + return; + } + + // 2. Parse payload + let event: LinearWebhookPayload; + try { + event = JSON.parse(payload) as LinearWebhookPayload; + } catch { + getLog().error('linear.payload_parse_failed'); + return; + } + + // 3. Only process issue update events where the state changed + if (event.action !== 'update' || !isIssueData(event) || !event.updatedFrom?.stateId) { + return; + } + + const issue = event.data; + + // 4. Only trigger when state transitions to "started" (In Progress) + if (issue.state.type !== 'started') { + return; + } + + // 5. Only trigger if assigned to the configured target + if (!isTargetAssignee(issue.assignee?.displayName, this.targetAssignee)) { + return; + } + + getLog().info( + { + issueId: issue.identifier, + team: issue.team.key, + assignee: issue.assignee?.displayName, + }, + 'linear.issue_triggered' + ); + + // 6. Dispatch within conversation lock + const conversationId = `linear/${issue.team.key}/${issue.identifier}`; + + await this.lockManager.acquireLock(conversationId, async () => { + await this.processIssue(conversationId, issue); + }); + } + + // ─── Internal ─────────────────────────────────────────────────────────────── + + private async processIssue(conversationId: string, issue: LinearIssueData): Promise { + // Track issue ID for comment posting + this.issueIdMap.set(conversationId, issue.id); + + // Resolve codebase from team mapping + const codebaseName = this.teamCodebaseMap.get(issue.team.key); + if (!codebaseName) { + getLog().warn( + { teamKey: issue.team.key, issueId: issue.identifier }, + 'linear.no_team_mapping' + ); + await this.postErrorComment( + issue.id, + `No codebase mapping found for team **${issue.team.key}**. ` + + 'Configure `linear.mappings` in `~/.archon/config.yaml`.' + ); + return; + } + + const codebase = await codebaseDb.findCodebaseByName(codebaseName); + if (!codebase) { + getLog().error( + { codebaseName, teamKey: issue.team.key, issueId: issue.identifier }, + 'linear.codebase_not_found' + ); + await this.postErrorComment( + issue.id, + `Codebase **${codebaseName}** not found. Register it first via the web UI or \`/clone\`.` + ); + return; + } + + // Get or create conversation + const conversation = await db.getOrCreateConversation('linear', conversationId); + await db.updateConversation(conversation.id, { codebase_id: codebase.id }); + + // Discover workflows and find the configured one + const cwd = codebase.default_cwd; + const { workflows: workflowEntries } = await discoverWorkflowsWithConfig(cwd, loadConfig, { + globalSearchPath: getArchonHome(), + }); + const allWorkflows = workflowEntries.map(w => w.workflow); + const workflow = resolveWorkflowName(this.defaultWorkflow, allWorkflows); + + if (!workflow) { + getLog().error( + { workflowName: this.defaultWorkflow, issueId: issue.identifier }, + 'linear.workflow_not_found' + ); + await this.postErrorComment( + issue.id, + `Workflow **${this.defaultWorkflow}** not found. ` + + 'Check `.archon/workflows/` in the repository or Archon defaults.' + ); + return; + } + + // Resolve isolation (worktree) + const isolationHints: IsolationHints = { + workflowType: 'issue', + workflowId: issue.identifier, + }; + + let resolvedCwd: string; + try { + const result = await validateAndResolveIsolation( + { ...conversation, codebase_id: codebase.id } as Conversation, + codebase, + this, + conversationId, + isolationHints + ); + resolvedCwd = result.cwd; + } catch (error) { + if (error instanceof IsolationBlockedError) { + getLog().warn( + { reason: error.reason, conversationId, issueId: issue.identifier }, + 'linear.isolation_blocked' + ); + return; // User already notified via sendMessage → Linear comment + } + throw error; + } + + // Build context message + const contextMessage = this.buildContextMessage(issue); + + // Notify that work is starting + await this.sendMessage( + conversationId, + `Starting workflow **${workflow.name}** for this issue.` + ); + + // Execute workflow + try { + const result = await executeWorkflow( + createWorkflowDeps(), + this, + conversationId, + resolvedCwd, + workflow, + contextMessage, + conversation.id, + codebase.id + ); + + if (!result.success) { + await this.sendMessage( + conversationId, + `Workflow **${workflow.name}** failed: ${result.error}` + ); + } else if (!('paused' in result) && result.summary) { + await this.sendMessage(conversationId, result.summary); + } + } catch (error) { + getLog().error( + { err: error as Error, workflowName: workflow.name, issueId: issue.identifier }, + 'linear.workflow_execution_failed' + ); + await this.sendMessage( + conversationId, + `Workflow **${workflow.name}** failed unexpectedly. Check Archon logs for details.` + ); + } + } + + private buildContextMessage(issue: LinearIssueData): string { + const priority = PRIORITY_LABELS[issue.priority] ?? `Priority ${issue.priority}`; + const labels = issue.labels.map(l => l.name).join(', ') || 'none'; + const description = issue.description?.trim() || '(No description provided)'; + + return [ + `## Linear Issue: ${issue.identifier}`, + '', + `**Title:** ${issue.title}`, + `**Team:** ${issue.team.name}`, + `**Priority:** ${priority}`, + `**Labels:** ${labels}`, + '', + '### Description', + '', + description, + '', + '---', + '', + 'Implement the changes described in this Linear issue.', + ].join('\n'); + } + + private async postErrorComment(issueId: string, message: string): Promise { + try { + await this.linearClient.createComment({ + issueId, + body: `${BOT_RESPONSE_MARKER}\n${message}`, + }); + } catch (error) { + getLog().error({ err: error as Error, issueId }, 'linear.error_comment_failed'); + } + } +} diff --git a/packages/adapters/src/pm/linear/auth.ts b/packages/adapters/src/pm/linear/auth.ts new file mode 100644 index 0000000000..21d9bc240d --- /dev/null +++ b/packages/adapters/src/pm/linear/auth.ts @@ -0,0 +1,34 @@ +/** + * Linear webhook authentication and authorization utilities. + * Signature verification follows HMAC-SHA256 pattern (same as GitHub adapter). + */ +import { createHmac, timingSafeEqual } from 'crypto'; + +/** + * Verify a Linear webhook signature (HMAC-SHA256). + * Linear sends the signature in the `linear-signature` header as a hex digest. + */ +export function verifyLinearSignature(payload: string, signature: string, secret: string): boolean { + const hmac = createHmac('sha256', secret); + const digest = hmac.update(payload).digest('hex'); + + const digestBuffer = Buffer.from(digest); + const signatureBuffer = Buffer.from(signature); + + if (digestBuffer.length !== signatureBuffer.length) { + return false; + } + + return timingSafeEqual(digestBuffer, signatureBuffer); +} + +/** + * Check if the issue assignee matches the configured target (case-insensitive). + */ +export function isTargetAssignee( + assigneeName: string | undefined, + configuredAssignee: string +): boolean { + if (!assigneeName) return false; + return assigneeName.toLowerCase() === configuredAssignee.toLowerCase(); +} diff --git a/packages/adapters/src/pm/linear/index.ts b/packages/adapters/src/pm/linear/index.ts new file mode 100644 index 0000000000..fb9cce1fa8 --- /dev/null +++ b/packages/adapters/src/pm/linear/index.ts @@ -0,0 +1 @@ +export { LinearAdapter } from './adapter'; diff --git a/packages/adapters/src/pm/linear/types.ts b/packages/adapters/src/pm/linear/types.ts new file mode 100644 index 0000000000..cb340c55fa --- /dev/null +++ b/packages/adapters/src/pm/linear/types.ts @@ -0,0 +1,70 @@ +/** + * Linear webhook event type definitions. + * Based on Linear's webhook API: https://linear.app/docs/webhooks + */ + +export interface LinearWebhookPayload { + action: 'create' | 'update' | 'remove'; + type: 'Issue' | 'Comment' | (string & {}); + data: LinearIssueData | LinearCommentData; + /** Previous values for updated fields (only present on 'update' actions) */ + updatedFrom?: Record; + createdAt: string; + organizationId: string; +} + +export interface LinearIssueState { + id: string; + name: string; + /** Linear state type — 'started' corresponds to "In Progress" */ + type: string; +} + +export interface LinearAssignee { + id: string; + name: string; + displayName: string; +} + +export interface LinearTeam { + id: string; + key: string; + name: string; +} + +export interface LinearLabel { + id: string; + name: string; +} + +export interface LinearIssueData { + id: string; + /** Human-readable identifier, e.g. "ENG-123" */ + identifier: string; + title: string; + description: string | null; + priority: number; + state: LinearIssueState; + assignee?: LinearAssignee; + team: LinearTeam; + labels: LinearLabel[]; + url: string; + project?: { id: string; name: string }; +} + +export interface LinearCommentData { + id: string; + body: string; + issueId: string; + userId: string; + issue: { id: string; identifier: string }; +} + +/** + * Type guard: check if webhook data is an issue payload. + */ +export function isIssueData( + payload: LinearWebhookPayload +): payload is LinearWebhookPayload & { data: LinearIssueData } { + return payload.type === 'Issue'; +} diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 6e3611c5b2..0665ac8e3f 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -59,6 +59,20 @@ export type AssistantDefaults = ProviderDefaultsMap & { codex: CodexProviderDefaults; }; +/** + * Linear webhook integration configuration. + * When configured, issues assigned to the target user that transition to + * "In Progress" automatically trigger a workflow execution. + */ +export interface LinearConfig { + /** Linear assignee display name to match (case-insensitive). @default 'archon' */ + assignee?: string; + /** Workflow name to trigger when an issue is activated. @default 'implement' */ + workflow?: string; + /** Maps Linear team keys to registered Archon codebase names. */ + mappings?: Record; +} + export interface GlobalConfig { /** * Bot display name (shown in messages) @@ -113,6 +127,12 @@ export interface GlobalConfig { */ maxConversations?: number; }; + + /** + * Linear webhook integration settings. + * Requires LINEAR_API_KEY and LINEAR_WEBHOOK_SECRET env vars. + */ + linear?: LinearConfig; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c5e928a98..40184cfc0e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,6 +83,7 @@ export { cloneRepository, registerRepository, type RegisterResult } from './hand // ============================================================================= export { type GlobalConfig, + type LinearConfig, type RepoConfig, type MergedConfig, type SafeConfig, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index aa0940cd8d..14f660ec63 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -60,7 +60,13 @@ registerCommunityProviders(); import { OpenAPIHono } from '@hono/zod-openapi'; import { validationErrorHook } from './routes/openapi-defaults'; -import { TelegramAdapter, GitHubAdapter, DiscordAdapter, SlackAdapter } from '@archon/adapters'; +import { + TelegramAdapter, + GitHubAdapter, + DiscordAdapter, + SlackAdapter, + LinearAdapter, +} from '@archon/adapters'; import { GiteaAdapter } from '@archon/adapters/community/forge/gitea'; import { GitLabAdapter } from '@archon/adapters/community/forge/gitlab'; import { WebAdapter } from './adapters/web'; @@ -76,6 +82,7 @@ import { startCleanupScheduler, stopCleanupScheduler, loadConfig, + loadGlobalConfig, logConfig, getPort, } from '@archon/core'; @@ -263,6 +270,7 @@ export async function startServer(opts: ServerOptions = {}): Promise { let gitlab: GitLabAdapter | null = null; let discord: DiscordAdapter | null = null; let slack: SlackAdapter | null = null; + let linear: LinearAdapter | null = null; if (!opts.skipPlatformAdapters) { // Check that at least one platform is configured @@ -273,8 +281,9 @@ export async function startServer(opts: ServerOptions = {}): Promise { process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET ); const hasGitLab = Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET); + const hasLinear = Boolean(process.env.LINEAR_API_KEY && process.env.LINEAR_WEBHOOK_SECRET); - if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab) { + if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab && !hasLinear) { getLog().warn('no_platform_adapters_configured'); } @@ -441,6 +450,23 @@ export async function startServer(opts: ServerOptions = {}): Promise { } else { getLog().info('slack_adapter_skipped'); } + + // Initialize Linear adapter (conditional) + if (process.env.LINEAR_API_KEY && process.env.LINEAR_WEBHOOK_SECRET) { + const globalConfig = await loadGlobalConfig(); + const linearConfig = globalConfig.linear ?? {}; + linear = new LinearAdapter( + process.env.LINEAR_API_KEY, + process.env.LINEAR_WEBHOOK_SECRET, + lockManager, + linearConfig.assignee ?? 'archon', + linearConfig.workflow ?? 'implement', + new Map(Object.entries(linearConfig.mappings ?? {})) + ); + await linear.start(); + } else { + getLog().info('linear_adapter_skipped'); + } } else { getLog().info('platform_adapters_skipped'); } @@ -543,6 +569,32 @@ export async function startServer(opts: ServerOptions = {}): Promise { getLog().info('gitlab_webhook_registered'); } + // Linear webhook endpoint + if (linear) { + app.post('/webhooks/linear', async c => { + try { + const signature = c.req.header('linear-signature'); + if (!signature) { + return c.json({ error: 'Missing signature header' }, 400); + } + + // CRITICAL: Use c.req.text() for raw body (signature verification) + const payload = await c.req.text(); + + // Process async (fire-and-forget for fast webhook response) + linear.handleWebhook(payload, signature).catch((error: unknown) => { + getLog().error({ err: error }, 'linear.webhook_processing_error'); + }); + + return c.text('OK', 200); + } catch (error) { + getLog().error({ err: error }, 'linear.webhook_endpoint_error'); + return c.json({ error: 'Internal server error' }, 500); + } + }); + getLog().info('linear_webhook_registered'); + } + // Health check endpoints app.get('/health', c => { return c.json({ status: 'ok' }); From 9ed0601974ab2409de9faed9d11da65c315ccd43 Mon Sep 17 00:00:00 2001 From: Joao Date: Sat, 18 Apr 2026 08:20:03 +0200 Subject: [PATCH 2/3] refactor(linear): route through handleMessage and harden adapter Bring the Linear adapter in line with the GitHub/Gitea/GitLab pattern by dispatching through the orchestrator's handleMessage entry point instead of calling executeWorkflow + isolation/discovery helpers directly. Also fixes restart-mid-run silent comment loss, swallowed sendMessage errors, positional-arg constructor sprawl, log-event naming, and a too-loose webhook type guard. - adapter: synthesize `/workflow run ` and pass rich issue context via HandleMessageContext.issueContext + isolationHints - adapter: drop in-memory issueIdMap; resolve UUID on demand via the Linear SDK using identifier parsed from the conversationId - adapter: propagate createComment errors instead of logging-and-returning - adapter: take LinearConfig options bag (4 args, was 6 positional) - adapter: remove dead BOT_RESPONSE_MARKER (no Comment events processed) - types: structurally validate isIssueData payload shape - orchestrator-agent: when a slash command has no inline args, fall back to issueContext so adapter-synthesized webhook triggers still receive the rich trigger context as their `\$ARGUMENTS` - server: pass globalConfig.linear; rename adapter_skipped / webhook_registered to dotted form per logging convention - tests: rewritten against the new dispatch path Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adapters/src/pm/linear/adapter.test.ts | 343 ++++++------------ packages/adapters/src/pm/linear/adapter.ts | 219 ++++------- packages/adapters/src/pm/linear/types.ts | 18 +- .../src/orchestrator/orchestrator-agent.ts | 7 +- packages/server/src/index.ts | 9 +- 5 files changed, 197 insertions(+), 399 deletions(-) diff --git a/packages/adapters/src/pm/linear/adapter.test.ts b/packages/adapters/src/pm/linear/adapter.test.ts index f447fa25f1..15c2115647 100644 --- a/packages/adapters/src/pm/linear/adapter.test.ts +++ b/packages/adapters/src/pm/linear/adapter.test.ts @@ -1,12 +1,11 @@ /** - * Unit tests for Linear adapter + * Unit tests for Linear adapter. * * Runs in its own bun test batch to avoid mock.module() pollution. */ import { describe, test, expect, mock, beforeEach } from 'bun:test'; import { createHmac } from 'crypto'; -// Mock logger before importing anything that uses it const mockLogger = { fatal: mock(() => undefined), error: mock(() => undefined), @@ -23,10 +22,8 @@ const mockLogger = { }; mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger), - getArchonHome: mock(() => '/tmp/archon-test'), })); -// Mock database modules const mockGetOrCreateConversation = mock(async () => ({ id: 'conv-linear-test', platform_type: 'linear', @@ -65,92 +62,31 @@ mock.module('@archon/core/db/codebases', () => ({ findCodebaseByName: mockFindCodebaseByName, })); -// Mock workflow execution dependencies -const mockExecuteWorkflow = mock(async () => ({ - success: true as const, - workflowRunId: 'run-123', - summary: 'Workflow completed successfully.', -})); - -mock.module('@archon/workflows/executor', () => ({ - executeWorkflow: mockExecuteWorkflow, -})); - -const mockResolveWorkflowName = mock((_name: string) => ({ - name: 'implement', - description: 'Implement changes', - nodes: [], -})); +// `handleMessage` is the canonical orchestrator entry point. The adapter +// must route through it (not directly into the workflow executor). +const mockHandleMessage = mock(async () => undefined); -mock.module('@archon/workflows/router', () => ({ - resolveWorkflowName: mockResolveWorkflowName, -})); +class MockConversationLockManager {} -const mockDiscoverWorkflowsWithConfig = mock(async () => ({ - workflows: [ - { - workflow: { name: 'implement', description: 'Implement changes', nodes: [] }, - source: 'bundled' as const, - }, - ], - errors: [], -})); - -mock.module('@archon/workflows/workflow-discovery', () => ({ - discoverWorkflowsWithConfig: mockDiscoverWorkflowsWithConfig, -})); - -// Mock isolation resolution -const mockValidateAndResolveIsolation = mock(async () => ({ - cwd: '/tmp/test-worktree', -})); - -mock.module('@archon/core/orchestrator', () => ({ - validateAndResolveIsolation: mockValidateAndResolveIsolation, -})); - -// Mock workflow deps -mock.module('@archon/core/workflows/store-adapter', () => ({ - createWorkflowDeps: mock(() => ({ - store: {}, - getAssistantClient: mock(), - loadConfig: mock(), - })), -})); - -// Mock config loader mock.module('@archon/core', () => ({ - loadConfig: mock(async () => ({})), + handleMessage: mockHandleMessage, + ConversationLockManager: MockConversationLockManager, })); -// Mock @linear/sdk const mockCreateComment = mock(async () => ({ success: true })); +const mockIssue = mock(async (identifier: string) => ({ id: `uuid-for-${identifier}` })); const MockLinearClient = mock(() => ({ createComment: mockCreateComment, + issue: mockIssue, })); mock.module('@linear/sdk', () => ({ LinearClient: MockLinearClient, })); -// Mock @archon/isolation -mock.module('@archon/isolation', () => ({ - IsolationBlockedError: class IsolationBlockedError extends Error { - reason: string; - constructor(message: string, reason: string) { - super(message); - this.name = 'IsolationBlockedError'; - this.reason = reason; - } - }, -})); - -// Now import the adapter under test import { LinearAdapter } from './adapter'; import { ConversationLockManager } from '@archon/core'; -// ─── Helpers ─────────────────────────────────────────────────────────────── - const WEBHOOK_SECRET = 'test-webhook-secret'; function signPayload(payload: string, secret = WEBHOOK_SECRET): string { @@ -203,7 +139,6 @@ function buildIssuePayload( }); } -// Create a mock lock manager that immediately executes handlers const mockLockManager = { acquireLock: mock(async (_id: string, handler: () => Promise) => { await handler(); @@ -221,48 +156,38 @@ const mockLockManager = { function createAdapter( teamMappings: Record = { ENG: 'my-org/my-repo' } ): LinearAdapter { - return new LinearAdapter( - 'fake-api-key', - WEBHOOK_SECRET, - mockLockManager, - 'archon', - 'implement', - new Map(Object.entries(teamMappings)) - ); + return new LinearAdapter('fake-api-key', WEBHOOK_SECRET, mockLockManager, { + assignee: 'archon', + workflow: 'implement', + mappings: teamMappings, + }); } -// ─── Tests ───────────────────────────────────────────────────────────────── - describe('LinearAdapter', () => { beforeEach(() => { mockLogger.error.mockClear(); mockLogger.warn.mockClear(); mockLogger.info.mockClear(); mockCreateComment.mockClear(); + mockIssue.mockClear(); mockGetOrCreateConversation.mockClear(); mockUpdateConversation.mockClear(); mockFindCodebaseByName.mockClear(); - mockExecuteWorkflow.mockClear(); - mockResolveWorkflowName.mockClear(); - mockDiscoverWorkflowsWithConfig.mockClear(); - mockValidateAndResolveIsolation.mockClear(); + mockHandleMessage.mockClear(); (mockLockManager.acquireLock as ReturnType).mockClear(); }); describe('platform interface', () => { test('returns batch streaming mode', () => { - const adapter = createAdapter(); - expect(adapter.getStreamingMode()).toBe('batch'); + expect(createAdapter().getStreamingMode()).toBe('batch'); }); test('returns linear platform type', () => { - const adapter = createAdapter(); - expect(adapter.getPlatformType()).toBe('linear'); + expect(createAdapter().getPlatformType()).toBe('linear'); }); test('ensureThread returns same conversation ID', async () => { - const adapter = createAdapter(); - const id = await adapter.ensureThread('linear/ENG/ENG-123'); + const id = await createAdapter().ensureThread('linear/ENG/ENG-123'); expect(id).toBe('linear/ENG/ENG-123'); }); }); @@ -270,20 +195,17 @@ describe('LinearAdapter', () => { describe('signature verification', () => { test('rejects invalid signature silently', async () => { const adapter = createAdapter(); - const payload = buildIssuePayload(); - await adapter.handleWebhook(payload, 'invalid-signature'); + await adapter.handleWebhook(buildIssuePayload(), 'invalid-signature'); expect(mockLogger.error).toHaveBeenCalledWith('linear.signature_verification_failed'); - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); test('accepts valid signature', async () => { const adapter = createAdapter(); const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); + await adapter.handleWebhook(payload, signPayload(payload)); - // Should proceed past signature verification expect(mockLogger.error).not.toHaveBeenCalledWith('linear.signature_verification_failed'); }); }); @@ -292,227 +214,168 @@ describe('LinearAdapter', () => { test('ignores non-update actions', async () => { const adapter = createAdapter(); const payload = buildIssuePayload({ action: 'create' }); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + await adapter.handleWebhook(payload, signPayload(payload)); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); test('ignores non-Issue types', async () => { const adapter = createAdapter(); const payload = buildIssuePayload({ type: 'Comment' }); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + await adapter.handleWebhook(payload, signPayload(payload)); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); test('ignores updates without state change', async () => { const adapter = createAdapter(); const payload = buildIssuePayload({ updatedFrom: { title: 'Old title' } }); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + await adapter.handleWebhook(payload, signPayload(payload)); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); test('ignores state changes that are not to "started"', async () => { const adapter = createAdapter(); const payload = buildIssuePayload({ stateType: 'completed' }); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + await adapter.handleWebhook(payload, signPayload(payload)); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); test('ignores issues not assigned to target user', async () => { const adapter = createAdapter(); const payload = buildIssuePayload({ assigneeName: 'someone-else' }); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + await adapter.handleWebhook(payload, signPayload(payload)); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); test('assignee matching is case-insensitive', async () => { const adapter = createAdapter(); const payload = buildIssuePayload({ assigneeName: 'Archon' }); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - // Should proceed to workflow execution - expect(mockExecuteWorkflow).toHaveBeenCalled(); + await adapter.handleWebhook(payload, signPayload(payload)); + expect(mockHandleMessage).toHaveBeenCalledTimes(1); }); }); describe('codebase resolution', () => { test('logs warning and posts comment when no team mapping exists', async () => { - const adapter = createAdapter({ PLATFORM: 'other-repo' }); + const adapter = createAdapter({ OTHER: 'other-repo' }); const payload = buildIssuePayload({ teamKey: 'ENG' }); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); + await adapter.handleWebhook(payload, signPayload(payload)); expect(mockLogger.warn).toHaveBeenCalled(); expect(mockCreateComment).toHaveBeenCalled(); - const commentBody = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; - expect(commentBody.body).toContain('No codebase mapping found'); - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + const body = mockCreateComment.mock.calls[0]?.[0] as { body: string }; + expect(body.body).toContain('No codebase mapping found'); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); test('logs error and posts comment when codebase not found in DB', async () => { mockFindCodebaseByName.mockResolvedValueOnce(null); const adapter = createAdapter(); const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); + await adapter.handleWebhook(payload, signPayload(payload)); expect(mockLogger.error).toHaveBeenCalled(); expect(mockCreateComment).toHaveBeenCalled(); - const commentBody = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; - expect(commentBody.body).toContain('not found'); - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); + const body = mockCreateComment.mock.calls[0]?.[0] as { body: string }; + expect(body.body).toContain('not found'); + expect(mockHandleMessage).not.toHaveBeenCalled(); }); }); - describe('workflow resolution', () => { - test('logs error when workflow not found', async () => { - mockResolveWorkflowName.mockReturnValueOnce( - undefined as unknown as ReturnType - ); + describe('orchestrator dispatch', () => { + test('calls handleMessage with synthesized slash command and rich issueContext', async () => { const adapter = createAdapter(); const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - expect(mockLogger.error).toHaveBeenCalled(); - expect(mockCreateComment).toHaveBeenCalled(); - const commentBody = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; - expect(commentBody.body).toContain('not found'); - expect(mockExecuteWorkflow).not.toHaveBeenCalled(); - }); - }); - - describe('workflow execution', () => { - test('executes workflow on valid trigger', async () => { - const adapter = createAdapter(); - const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); + await adapter.handleWebhook(payload, signPayload(payload)); expect(mockGetOrCreateConversation).toHaveBeenCalledWith('linear', 'linear/ENG/ENG-123'); expect(mockUpdateConversation).toHaveBeenCalled(); - expect(mockExecuteWorkflow).toHaveBeenCalledTimes(1); - }); - - test('posts start notification before execution', async () => { - const adapter = createAdapter(); - const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - // First comment: start notification, second: summary - expect(mockCreateComment).toHaveBeenCalled(); - const firstCall = mockCreateComment.mock.calls[0]?.[0] as { body: string }; - expect(firstCall.body).toContain('Starting workflow'); + expect(mockHandleMessage).toHaveBeenCalledTimes(1); + + const args = mockHandleMessage.mock.calls[0] as unknown[]; + expect(args[0]).toBe(adapter); + expect(args[1]).toBe('linear/ENG/ENG-123'); + expect(args[2]).toBe('/workflow run implement'); + + const ctx = args[3] as { + issueContext: string; + isolationHints: { workflowType: string; workflowId: string }; + }; + expect(ctx.issueContext).toContain('ENG-123'); + expect(ctx.issueContext).toContain('Implement user authentication'); + expect(ctx.issueContext).toContain('Urgent'); + expect(ctx.issueContext).toContain('backend'); + expect(ctx.issueContext).toContain('JWT-based auth'); + expect(ctx.isolationHints).toEqual({ + workflowType: 'issue', + workflowId: 'ENG-123', + }); }); - test('posts summary on successful completion', async () => { - mockExecuteWorkflow.mockResolvedValueOnce({ - success: true as const, - workflowRunId: 'run-456', - summary: 'All tasks completed.', + test('skips re-linking codebase when conversation already linked', async () => { + mockGetOrCreateConversation.mockResolvedValueOnce({ + id: 'conv-linear-test', + platform_type: 'linear', + platform_conversation_id: 'linear/ENG/ENG-123', + codebase_id: 'codebase-test', + cwd: null, + isolation_env_id: null, + ai_assistant_type: 'claude', + title: null, + hidden: false, + deleted_at: null, + last_activity_at: null, + created_at: new Date(), + updated_at: new Date(), }); const adapter = createAdapter(); const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); + await adapter.handleWebhook(payload, signPayload(payload)); - // Last comment should be the summary - const lastCall = mockCreateComment.mock.calls[ - mockCreateComment.mock.calls.length - 1 - ]?.[0] as { body: string }; - expect(lastCall.body).toContain('All tasks completed.'); + expect(mockUpdateConversation).not.toHaveBeenCalled(); + expect(mockHandleMessage).toHaveBeenCalled(); }); - test('posts error on workflow failure', async () => { - mockExecuteWorkflow.mockResolvedValueOnce({ - success: false as const, - error: 'Node "build" failed', - }); + test('posts error comment when handleMessage throws', async () => { + mockHandleMessage.mockRejectedValueOnce(new Error('Boom')); const adapter = createAdapter(); const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); + await adapter.handleWebhook(payload, signPayload(payload)); + expect(mockLogger.error).toHaveBeenCalled(); const lastCall = mockCreateComment.mock.calls[ mockCreateComment.mock.calls.length - 1 ]?.[0] as { body: string }; - expect(lastCall.body).toContain('failed'); - expect(lastCall.body).toContain('Node "build" failed'); - }); - }); - - describe('context message', () => { - test('builds context with issue details', async () => { - const adapter = createAdapter(); - const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - // The context message is the 6th argument to executeWorkflow - const callArgs = mockExecuteWorkflow.mock.calls[0] as unknown[]; - const contextMessage = callArgs[5] as string; - expect(contextMessage).toContain('ENG-123'); - expect(contextMessage).toContain('Implement user authentication'); - expect(contextMessage).toContain('Urgent'); - expect(contextMessage).toContain('backend'); - expect(contextMessage).toContain('JWT-based auth'); + expect(lastCall.body).toContain('Failed to dispatch workflow'); + expect(lastCall.body).toContain('Boom'); }); }); describe('sendMessage', () => { - test('posts comment with bot marker', async () => { + test('resolves issue UUID via Linear SDK on demand and posts comment', async () => { const adapter = createAdapter(); - // Trigger a webhook to populate issueIdMap - const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - mockCreateComment.mockClear(); await adapter.sendMessage('linear/ENG/ENG-123', 'Test message'); + expect(mockIssue).toHaveBeenCalledWith('ENG-123'); expect(mockCreateComment).toHaveBeenCalledTimes(1); const call = mockCreateComment.mock.calls[0]?.[0] as { issueId: string; body: string }; - expect(call.issueId).toBe('issue-uuid-123'); - expect(call.body).toContain(''); - expect(call.body).toContain('Test message'); + expect(call.issueId).toBe('uuid-for-ENG-123'); + expect(call.body).toBe('Test message'); }); - test('warns when issue ID not found for conversation', async () => { + test('throws on malformed conversationId', async () => { const adapter = createAdapter(); - await adapter.sendMessage('linear/UNKNOWN/UNKNOWN-1', 'Test'); - - expect(mockLogger.warn).toHaveBeenCalled(); + await expect(adapter.sendMessage('slack/C123', 'x')).rejects.toThrow( + 'Invalid Linear conversationId' + ); expect(mockCreateComment).not.toHaveBeenCalled(); }); - }); - describe('isolation', () => { - test('passes correct isolation hints', async () => { + test('propagates createComment errors instead of swallowing', async () => { + mockCreateComment.mockRejectedValueOnce(new Error('Linear API down')); const adapter = createAdapter(); - const payload = buildIssuePayload(); - const signature = signPayload(payload); - await adapter.handleWebhook(payload, signature); - - const call = mockValidateAndResolveIsolation.mock.calls[0] as unknown[]; - const hints = call[4] as IsolationHints; - expect(hints).toEqual({ - workflowType: 'issue', - workflowId: 'ENG-123', - }); + await expect(adapter.sendMessage('linear/ENG/ENG-123', 'x')).rejects.toThrow( + 'Linear API down' + ); }); }); }); - -// Type import for test assertions -import type { IsolationHints } from '@archon/isolation'; diff --git a/packages/adapters/src/pm/linear/adapter.ts b/packages/adapters/src/pm/linear/adapter.ts index 9525c1e7f9..94ccca92c2 100644 --- a/packages/adapters/src/pm/linear/adapter.ts +++ b/packages/adapters/src/pm/linear/adapter.ts @@ -1,41 +1,29 @@ /** - * Linear platform adapter — triggers workflows when issues move to "In Progress". - * Webhook-driven (no polling), follows the forge adapter pattern (GitHub, Gitea). + * Linear platform adapter — triggers a workflow when an issue assigned to the + * configured user transitions to "In Progress". Routes through the standard + * orchestrator entry point (`handleMessage`) so paused-run handling, slash + * command processing, and any future orchestrator behaviour apply uniformly. */ import { LinearClient } from '@linear/sdk'; -import type { IPlatformAdapter, MessageMetadata, Conversation } from '@archon/core'; +import type { IPlatformAdapter, MessageMetadata, LinearConfig } from '@archon/core'; import type { IsolationHints } from '@archon/isolation'; -import { IsolationBlockedError } from '@archon/isolation'; -import { ConversationLockManager } from '@archon/core'; -import { createWorkflowDeps } from '@archon/core/workflows/store-adapter'; -import { validateAndResolveIsolation } from '@archon/core/orchestrator'; +import { ConversationLockManager, handleMessage } from '@archon/core'; import * as db from '@archon/core/db/conversations'; import * as codebaseDb from '@archon/core/db/codebases'; -import { loadConfig } from '@archon/core'; -import { executeWorkflow } from '@archon/workflows/executor'; -import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; -import { resolveWorkflowName } from '@archon/workflows/router'; -import { getArchonHome } from '@archon/paths'; import { createLogger } from '@archon/paths'; import { verifyLinearSignature, isTargetAssignee } from './auth'; import { splitIntoParagraphChunks } from '../../utils/message-splitting'; import type { LinearWebhookPayload, LinearIssueData } from './types'; import { isIssueData } from './types'; -/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; function getLog(): ReturnType { if (!cachedLog) cachedLog = createLogger('adapter.linear'); return cachedLog; } -/** Max comment length (Linear has no strict limit, but keep comments reasonable) */ const MAX_LENGTH = 65000; -/** Hidden marker added to bot comments to prevent self-triggering loops */ -const BOT_RESPONSE_MARKER = ''; - -/** Map Linear priority numbers to human-readable labels */ const PRIORITY_LABELS: Record = { 0: 'No priority', 1: 'Urgent', @@ -44,30 +32,45 @@ const PRIORITY_LABELS: Record = { 4: 'Low', }; +const CONVERSATION_ID_PREFIX = 'linear/'; + +interface ParsedConversationId { + team: string; + identifier: string; +} + +/** + * Parse a `linear/{team}/{identifier}` conversationId. Returns undefined for + * conversation IDs from other platforms or malformed strings. + */ +function parseConversationId(conversationId: string): ParsedConversationId | undefined { + if (!conversationId.startsWith(CONVERSATION_ID_PREFIX)) return undefined; + const rest = conversationId.slice(CONVERSATION_ID_PREFIX.length); + const slash = rest.indexOf('/'); + if (slash <= 0 || slash === rest.length - 1) return undefined; + return { team: rest.slice(0, slash), identifier: rest.slice(slash + 1) }; +} + export class LinearAdapter implements IPlatformAdapter { private readonly linearClient: LinearClient; private readonly webhookSecret: string; private readonly lockManager: ConversationLockManager; private readonly targetAssignee: string; private readonly defaultWorkflow: string; - private readonly teamCodebaseMap: Map; - /** Maps conversationId → Linear issue ID (UUID) for comment posting */ - private readonly issueIdMap = new Map(); + private readonly teamCodebaseMap: ReadonlyMap; constructor( apiKey: string, webhookSecret: string, lockManager: ConversationLockManager, - targetAssignee = 'archon', - defaultWorkflow = 'implement', - teamCodebaseMap: Map = new Map() + config: LinearConfig = {} ) { this.linearClient = new LinearClient({ apiKey }); this.webhookSecret = webhookSecret; this.lockManager = lockManager; - this.targetAssignee = targetAssignee; - this.defaultWorkflow = defaultWorkflow; - this.teamCodebaseMap = teamCodebaseMap; + this.targetAssignee = config.assignee ?? 'archon'; + this.defaultWorkflow = config.workflow ?? 'implement'; + this.teamCodebaseMap = new Map(Object.entries(config.mappings ?? {})); } // ─── IPlatformAdapter ─────────────────────────────────────────────────────── @@ -77,28 +80,14 @@ export class LinearAdapter implements IPlatformAdapter { message: string, _metadata?: MessageMetadata ): Promise { - const issueId = this.issueIdMap.get(conversationId); - if (!issueId) { - getLog().warn({ conversationId }, 'linear.send_message_no_issue_id'); - return; - } - + const issueId = await this.resolveIssueUuid(conversationId); const chunks = splitIntoParagraphChunks(message, MAX_LENGTH); for (const chunk of chunks) { - const body = `${BOT_RESPONSE_MARKER}\n${chunk}`; - try { - await this.linearClient.createComment({ issueId, body }); - } catch (error) { - getLog().error( - { err: error as Error, issueId, conversationId }, - 'linear.comment_create_failed' - ); - } + await this.linearClient.createComment({ issueId, body: chunk }); } } async ensureThread(originalConversationId: string, _messageContext?: unknown): Promise { - // Linear issues are inherently threaded — no-op return originalConversationId; } @@ -111,23 +100,21 @@ export class LinearAdapter implements IPlatformAdapter { } async start(): Promise { - getLog().info('linear_adapter_started'); + getLog().info('linear.adapter_started'); } stop(): void { - getLog().info('linear_adapter_stopped'); + getLog().info('linear.adapter_stopped'); } // ─── Webhook Processing ───────────────────────────────────────────────────── async handleWebhook(payload: string, signature: string): Promise { - // 1. Verify signature if (!verifyLinearSignature(payload, signature, this.webhookSecret)) { getLog().error('linear.signature_verification_failed'); return; } - // 2. Parse payload let event: LinearWebhookPayload; try { event = JSON.parse(payload) as LinearWebhookPayload; @@ -136,22 +123,17 @@ export class LinearAdapter implements IPlatformAdapter { return; } - // 3. Only process issue update events where the state changed + // Only process issue update events with a state transition. if (event.action !== 'update' || !isIssueData(event) || !event.updatedFrom?.stateId) { return; } const issue = event.data; - // 4. Only trigger when state transitions to "started" (In Progress) - if (issue.state.type !== 'started') { - return; - } + // Only trigger when state transitions to "started" (In Progress). + if (issue.state.type !== 'started') return; - // 5. Only trigger if assigned to the configured target - if (!isTargetAssignee(issue.assignee?.displayName, this.targetAssignee)) { - return; - } + if (!isTargetAssignee(issue.assignee?.displayName, this.targetAssignee)) return; getLog().info( { @@ -162,8 +144,7 @@ export class LinearAdapter implements IPlatformAdapter { 'linear.issue_triggered' ); - // 6. Dispatch within conversation lock - const conversationId = `linear/${issue.team.key}/${issue.identifier}`; + const conversationId = `${CONVERSATION_ID_PREFIX}${issue.team.key}/${issue.identifier}`; await this.lockManager.acquireLock(conversationId, async () => { await this.processIssue(conversationId, issue); @@ -173,18 +154,14 @@ export class LinearAdapter implements IPlatformAdapter { // ─── Internal ─────────────────────────────────────────────────────────────── private async processIssue(conversationId: string, issue: LinearIssueData): Promise { - // Track issue ID for comment posting - this.issueIdMap.set(conversationId, issue.id); - - // Resolve codebase from team mapping const codebaseName = this.teamCodebaseMap.get(issue.team.key); if (!codebaseName) { getLog().warn( { teamKey: issue.team.key, issueId: issue.identifier }, 'linear.no_team_mapping' ); - await this.postErrorComment( - issue.id, + await this.sendMessage( + conversationId, `No codebase mapping found for team **${issue.team.key}**. ` + 'Configure `linear.mappings` in `~/.archon/config.yaml`.' ); @@ -197,108 +174,46 @@ export class LinearAdapter implements IPlatformAdapter { { codebaseName, teamKey: issue.team.key, issueId: issue.identifier }, 'linear.codebase_not_found' ); - await this.postErrorComment( - issue.id, + await this.sendMessage( + conversationId, `Codebase **${codebaseName}** not found. Register it first via the web UI or \`/clone\`.` ); return; } - // Get or create conversation + // Link conversation → codebase BEFORE handing off to the orchestrator, + // mirroring the GitHub adapter pattern (forge/github/adapter.ts). const conversation = await db.getOrCreateConversation('linear', conversationId); - await db.updateConversation(conversation.id, { codebase_id: codebase.id }); - - // Discover workflows and find the configured one - const cwd = codebase.default_cwd; - const { workflows: workflowEntries } = await discoverWorkflowsWithConfig(cwd, loadConfig, { - globalSearchPath: getArchonHome(), - }); - const allWorkflows = workflowEntries.map(w => w.workflow); - const workflow = resolveWorkflowName(this.defaultWorkflow, allWorkflows); - - if (!workflow) { - getLog().error( - { workflowName: this.defaultWorkflow, issueId: issue.identifier }, - 'linear.workflow_not_found' - ); - await this.postErrorComment( - issue.id, - `Workflow **${this.defaultWorkflow}** not found. ` + - 'Check `.archon/workflows/` in the repository or Archon defaults.' - ); - return; + if (conversation.codebase_id !== codebase.id) { + await db.updateConversation(conversation.id, { codebase_id: codebase.id }); } - // Resolve isolation (worktree) const isolationHints: IsolationHints = { workflowType: 'issue', workflowId: issue.identifier, }; - let resolvedCwd: string; - try { - const result = await validateAndResolveIsolation( - { ...conversation, codebase_id: codebase.id } as Conversation, - codebase, - this, - conversationId, - isolationHints - ); - resolvedCwd = result.cwd; - } catch (error) { - if (error instanceof IsolationBlockedError) { - getLog().warn( - { reason: error.reason, conversationId, issueId: issue.identifier }, - 'linear.isolation_blocked' - ); - return; // User already notified via sendMessage → Linear comment - } - throw error; - } + const issueContext = this.buildIssueContext(issue); + const finalMessage = `/workflow run ${this.defaultWorkflow}`; - // Build context message - const contextMessage = this.buildContextMessage(issue); - - // Notify that work is starting - await this.sendMessage( - conversationId, - `Starting workflow **${workflow.name}** for this issue.` - ); - - // Execute workflow try { - const result = await executeWorkflow( - createWorkflowDeps(), - this, - conversationId, - resolvedCwd, - workflow, - contextMessage, - conversation.id, - codebase.id - ); - - if (!result.success) { - await this.sendMessage( - conversationId, - `Workflow **${workflow.name}** failed: ${result.error}` - ); - } else if (!('paused' in result) && result.summary) { - await this.sendMessage(conversationId, result.summary); - } + await handleMessage(this, conversationId, finalMessage, { + issueContext, + isolationHints, + }); } catch (error) { getLog().error( - { err: error as Error, workflowName: workflow.name, issueId: issue.identifier }, - 'linear.workflow_execution_failed' + { err: error as Error, issueId: issue.identifier, workflow: this.defaultWorkflow }, + 'linear.handle_message_failed' ); await this.sendMessage( conversationId, - `Workflow **${workflow.name}** failed unexpectedly. Check Archon logs for details.` + `Failed to dispatch workflow **${this.defaultWorkflow}**: ${(error as Error).message}` ); } } - private buildContextMessage(issue: LinearIssueData): string { + private buildIssueContext(issue: LinearIssueData): string { const priority = PRIORITY_LABELS[issue.priority] ?? `Priority ${issue.priority}`; const labels = issue.labels.map(l => l.name).join(', ') || 'none'; const description = issue.description?.trim() || '(No description provided)'; @@ -321,14 +236,18 @@ export class LinearAdapter implements IPlatformAdapter { ].join('\n'); } - private async postErrorComment(issueId: string, message: string): Promise { - try { - await this.linearClient.createComment({ - issueId, - body: `${BOT_RESPONSE_MARKER}\n${message}`, - }); - } catch (error) { - getLog().error({ err: error as Error, issueId }, 'linear.error_comment_failed'); + /** + * Resolve a Linear issue UUID from a conversationId. Linear's `issue` query + * accepts either the UUID or the human identifier (e.g. "ENG-123"), so we + * derive it on demand from the conversationId rather than caching in memory + * (which would break across restarts). + */ + private async resolveIssueUuid(conversationId: string): Promise { + const parsed = parseConversationId(conversationId); + if (!parsed) { + throw new Error(`Invalid Linear conversationId: ${conversationId}`); } + const issue = await this.linearClient.issue(parsed.identifier); + return issue.id; } } diff --git a/packages/adapters/src/pm/linear/types.ts b/packages/adapters/src/pm/linear/types.ts index cb340c55fa..21c0118661 100644 --- a/packages/adapters/src/pm/linear/types.ts +++ b/packages/adapters/src/pm/linear/types.ts @@ -61,10 +61,24 @@ export interface LinearCommentData { } /** - * Type guard: check if webhook data is an issue payload. + * Type guard: check if webhook data is an issue payload. Performs a structural + * check so a malformed payload with `type: 'Issue'` but the wrong shape does + * not slip past discriminator checks downstream. */ export function isIssueData( payload: LinearWebhookPayload ): payload is LinearWebhookPayload & { data: LinearIssueData } { - return payload.type === 'Issue'; + if (payload.type !== 'Issue') return false; + const data = payload.data as Partial | null | undefined; + if (!data || typeof data !== 'object') return false; + return ( + typeof data.id === 'string' && + typeof data.identifier === 'string' && + typeof data.state === 'object' && + data.state !== null && + typeof data.state.type === 'string' && + typeof data.team === 'object' && + data.team !== null && + typeof data.team.key === 'string' + ); } diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index d5eb9397b3..60bb7bb72e 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -705,12 +705,17 @@ export async function handleMessage( await platform.sendMessage(conversationId, result.message); if (result.workflow) { + // Prefer explicit args from the slash command. When absent (e.g. an + // adapter synthesised `/workflow run ` with no inline args), + // fall back to issueContext so webhook-triggered workflows still + // receive the rich trigger context as their `$ARGUMENTS`. + const workflowArgs = result.workflow.args || issueContext || message; await handleWorkflowRunCommand( platform, conversationId, conversation, result.workflow.definition, - result.workflow.args ?? message, + workflowArgs, isolationHints ); } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 14f660ec63..c36b498ce6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -454,18 +454,15 @@ export async function startServer(opts: ServerOptions = {}): Promise { // Initialize Linear adapter (conditional) if (process.env.LINEAR_API_KEY && process.env.LINEAR_WEBHOOK_SECRET) { const globalConfig = await loadGlobalConfig(); - const linearConfig = globalConfig.linear ?? {}; linear = new LinearAdapter( process.env.LINEAR_API_KEY, process.env.LINEAR_WEBHOOK_SECRET, lockManager, - linearConfig.assignee ?? 'archon', - linearConfig.workflow ?? 'implement', - new Map(Object.entries(linearConfig.mappings ?? {})) + globalConfig.linear ?? {} ); await linear.start(); } else { - getLog().info('linear_adapter_skipped'); + getLog().info('linear.adapter_skipped'); } } else { getLog().info('platform_adapters_skipped'); @@ -592,7 +589,7 @@ export async function startServer(opts: ServerOptions = {}): Promise { return c.json({ error: 'Internal server error' }, 500); } }); - getLog().info('linear_webhook_registered'); + getLog().info('linear.webhook_registered'); } // Health check endpoints From 3b480195117662bc1b487a5f5ca377fd6e2af095 Mon Sep 17 00:00:00 2001 From: Joao Date: Sat, 18 Apr 2026 18:41:12 +0200 Subject: [PATCH 3/3] fix(linear,claude,docker): unblock reviewer nodes + scaffold validator Three fixes discovered while running the first end-to-end Linear-triggered workflow on a clean VPS deploy: 1. linear adapter: match assignee against either `displayName` or `name`. Linear's webhook payload reliably includes `name` but `displayName` is optional and frequently absent, so the previous check silently skipped every webhook for users without a short-handle set. 2. claude provider: initialize `options.hooks` before merging YAML-declared hooks. The warnings-extraction path in sendQuery passes an empty throwaway options object; `applyNodeConfig` then crashed with "undefined is not an object" whenever a node had `hooks:` in YAML (e.g. the read-only reviewer nodes). 3. Dockerfile: install `jq`. Repo-local bash workflow nodes (e.g. validate-scaffold.sh) depend on it for JSON-artifact validation; without it the script reports "Contract file is not valid JSON" for any input. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 2 ++ packages/adapters/src/pm/linear/adapter.ts | 8 ++++++-- packages/providers/src/claude/provider.ts | 9 ++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 93a537525b..7d87df9457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,6 +74,8 @@ RUN apt-get update && apt-get install -y \ postgresql-client \ # Chromium for agent-browser E2E testing (drives browser via CDP) chromium \ + # jq for bash-node scripts that parse JSON artifacts + jq \ && rm -rf /var/lib/apt/lists/* # Install GitHub CLI diff --git a/packages/adapters/src/pm/linear/adapter.ts b/packages/adapters/src/pm/linear/adapter.ts index 94ccca92c2..6650dca543 100644 --- a/packages/adapters/src/pm/linear/adapter.ts +++ b/packages/adapters/src/pm/linear/adapter.ts @@ -133,13 +133,17 @@ export class LinearAdapter implements IPlatformAdapter { // Only trigger when state transitions to "started" (In Progress). if (issue.state.type !== 'started') return; - if (!isTargetAssignee(issue.assignee?.displayName, this.targetAssignee)) return; + // Linear's webhook payload reliably includes `name` (full name) but + // `displayName` (short handle) is optional and often absent. Match + // against either so config can point at whichever field the user sees. + const assigneeCandidates = [issue.assignee?.displayName, issue.assignee?.name]; + if (!assigneeCandidates.some(n => isTargetAssignee(n, this.targetAssignee))) return; getLog().info( { issueId: issue.identifier, team: issue.team.key, - assignee: issue.assignee?.displayName, + assignee: issue.assignee?.displayName ?? issue.assignee?.name, }, 'linear.issue_triggered' ); diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index 26935bf373..aa7dab822a 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -379,8 +379,15 @@ async function applyNodeConfig( nodeConfig.hooks as Record ); if (Object.keys(builtHooks).length > 0) { + // Some callers (e.g. the warnings-extraction path in sendQuery) pass a + // throwaway options object with no `hooks` set. Initialize defensively so + // the merge below doesn't crash with "undefined is not an object" when + // a node declares `hooks:` in YAML. + if (!options.hooks) { + options.hooks = {} as SDKHooksMap; + } // Merge with existing hooks (PostToolUse capture hook) - const existingHooks = options.hooks as SDKHooksMap | undefined; + const existingHooks = options.hooks as SDKHooksMap; for (const [event, matchers] of Object.entries(builtHooks)) { if (!matchers) continue; const existing = existingHooks?.[event] as HookCallbackMatcher[] | undefined;