From a6f97c8bbca54f722149b774665eb2cd6b416739 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 17:09:15 +0800 Subject: [PATCH] feat(integrations): align linear parity --- cmd/route_parity/main.go | 8 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 16 +- docs/parity/gochat_routes.txt | 3 +- docs/parity/route_parity.md | 10 +- .../api/v1/linear_integration_handler.go | 74 ++- .../api/v1/linear_integration_handler_test.go | 14 + internal/repository/integration_hook_repo.go | 5 + .../linear_notion_integration_service.go | 550 ++++++++++++++++-- .../linear_notion_integration_service_test.go | 171 +++++- 9 files changed, 739 insertions(+), 112 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 723fe566..c2f4d08f 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -233,6 +233,14 @@ var criticalRoutes = []route{ {Method: "GET", Path: "/api/v1/accounts/:account_id/integrations/slack/list_all_channels", Controller: "api/v1/accounts/integrations/slack#list_all_channels", Source: "routes.rb:352"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/integrations/dyte/create_a_meeting", Controller: "api/v1/accounts/integrations/dyte#create_a_meeting", Source: "routes.rb:357"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/integrations/dyte/add_participant_to_meeting", Controller: "api/v1/accounts/integrations/dyte#add_participant_to_meeting", Source: "routes.rb:358"}, + {Method: "DELETE", Path: "/api/v1/accounts/:account_id/integrations/linear", Controller: "api/v1/accounts/integrations/linear#destroy", Source: "routes.rb:366"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/integrations/linear/teams", Controller: "api/v1/accounts/integrations/linear#teams", Source: "routes.rb:367"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/integrations/linear/team_entities", Controller: "api/v1/accounts/integrations/linear#team_entities", Source: "routes.rb:368"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/integrations/linear/create_issue", Controller: "api/v1/accounts/integrations/linear#create_issue", Source: "routes.rb:369"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/integrations/linear/link_issue", Controller: "api/v1/accounts/integrations/linear#link_issue", Source: "routes.rb:370"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/integrations/linear/unlink_issue", Controller: "api/v1/accounts/integrations/linear#unlink_issue", Source: "routes.rb:371"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/integrations/linear/search_issue", Controller: "api/v1/accounts/integrations/linear#search_issue", Source: "routes.rb:372"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/integrations/linear/linked_issues", Controller: "api/v1/accounts/integrations/linear#linked_issues", Source: "routes.rb:373"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/notifications/", Controller: "api/v1/accounts/notifications#index", Source: "routes.rb:283"}, {Method: "PUT", Path: "/api/v1/accounts/:account_id/notifications/:notification_id", Controller: "api/v1/accounts/notifications#update", Source: "routes.rb:283"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 5f334af4..1194a8b7 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,16 +49,16 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 Slack integration parity checkpoint, prepared as `feat(integrations): align slack parity`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): align slack parity`. +- Current tracking checkpoint: 2026-06-06 Linear integration parity checkpoint, prepared as `feat(integrations): align linear parity`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): align linear parity`. - Latest documentation/tooling checkpoint: this tracker update for P3.24 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: Slack integration create/update/delete/list channels from `reference/chatwoot/config/routes.rb:350-352`, `Api::V1::Accounts::Integrations::SlackController`, `Integrations::Slack::HookBuilder`, `Integrations::Slack::ChannelBuilder`, Slack Jbuilder app/hook partials, request specs, and the reused dashboard `integrations.js` API are implemented. GoChat now exposes no-trailing and trailing `POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/integrations/slack`, returns raw Chatwoot app payloads for create/update, returns raw channel arrays for `list_all_channels`, uses Slack OAuth code exchange on create, persists disabled Slack hooks with `app_id: slack`, updates `reference_id/settings/status` only after matching a real Slack channel, joins public channels, and returns empty `200 OK` deletes. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: Linear integration account routes from `reference/chatwoot/config/routes.rb:365-373`, `Api::V1::Accounts::Integrations::LinearController`, `Integrations::Linear::ProcessorService`, `Linear`, `Linear::Queries`, `Linear::Mutations`, `Linear::ActivityMessageService`, request specs, and the reused dashboard `linear.js` API are implemented. GoChat now exposes no-trailing and trailing Linear destroy plus teams/team-entities/create/link/unlink/search/linked-issues routes, proxies Linear GraphQL through fakeable provider clients, returns raw Chatwoot payloads instead of local envelopes, maps provider failures to `422 { error }`, resolves conversations by account-scoped `display_id`, builds Chatwoot frontend conversation links, creates Linear activity messages, revokes tokens on delete, and returns empty `200 OK` deletes. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. -- `go test ./...` passes when run outside the restricted socket sandbox; focused Slack handler/service/route tests pass in the sandbox. -- Route dump succeeds with `939` registered routes after Slack no-trailing and `PUT` aliases. +- `go test ./...` passes when run outside the restricted socket sandbox; focused Linear handler/service tests pass in the sandbox. +- Route dump succeeds with `939` registered routes after Linear no-trailing destroy tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 392 Chatwoot routes: 379 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. +- Tracked frontend-critical route audit covers 400 Chatwoot routes: 387 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -140,10 +140,11 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.25 Linear integration parity | Implemented for reused dashboard Linear issue flow: no-trailing and trailing destroy routes are registered, teams/team_entities/search/linked/create/link/unlink return raw Chatwoot payloads, provider errors return `422 { error }`, blank search returns Chatwoot's exact search-string error, requests proxy Linear GraphQL via fakeable clients, delete revokes Linear tokens and returns empty `200 OK`, create/link/unlink resolve conversations by account display ID, conversation links use the reused frontend URL, and successful mutations create Chatwoot-style Linear activity messages. | Keep in Review; reopen only if live Linear OAuth/GraphQL smoke exposes token refresh behavior, GraphQL query/mutation field drift, activity job queue semantics, or frontend payload drift beyond the inspected controller/processor/client/spec contract. | Focused Linear handler/service tests, route dump/parity regeneration (`939` routes; `387 exact`, `13 parameter-compatible`, `0 missing out of 400`), sandbox package tests, escalated full `go test ./...`, and `git diff --check` passed. | | 0 | P3.24 Slack integration parity | Implemented for reused dashboard Slack settings flow: no-trailing and trailing singleton routes are registered for create/update/delete, `PUT` and `PATCH` update both work, create accepts frontend `code` and exchanges it for a Slack access token, hooks are persisted with `app_id: slack` and disabled status, update accepts frontend `reference_id`, fetches real private/public Slack channels with pagination, joins public channels, persists `reference_id/settings.channel_name/status`, create/update return raw Chatwoot app payloads with hooks, list-all returns raw channel arrays, invalid channels return Chatwoot's `422 { error }`, and delete returns empty `200 OK`. | Keep in Review; reopen only if live Slack OAuth/channel smoke exposes OAuth redirect, provider error, app serializer, or Slack channel pagination drift beyond the inspected controller/builder/spec/frontend contract. | Focused Slack handler/service tests, route dump/parity regeneration, sandbox focused `go test`, escalated full `go test ./...`, and `git diff --check` passed. | | 0 | P3.23 nested contact inbox creation API | Implemented for Chatwoot nested contact inbox creation: raw JSON/form/query params are accepted, contact and inbox are account-scoped, missing source IDs are generated through Chatwoot channel rules, duplicate contact+inbox+source rows are returned idempotently, `hmac_verified` is persisted on creation, and the response is raw `{ source_id, inbox }` rather than the local model/envelope. | Keep in Review; reopen only if live CRM/new-conversation smoke exposes inbox access-policy, unsupported channel, or serializer drift beyond the inspected controller/builder/Jbuilder contract. | Focused nested ContactInbox handler/service/repository tests, route parity check, full `go test ./...`, and `git diff --check` passed. | | 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. | -| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 392-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after Slack parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 400-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Linear routes from `routes.rb:365-373`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Linear GraphQL issue payload behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after Linear parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. | | 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 5 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | @@ -2439,3 +2440,4 @@ Verification milestone gates: - 2026-06-06: Documentation checkpoint prepared as `docs: land contact inbox parity plan`; worktree was clean at `2072396 feat(reporting): align account events`, and the active plan now lands P3.23 nested contact inbox creation as the next executable slice. The row records Chatwoot `contacts/contact_inboxes#create`, `ContactInboxBuilder`, `HmacConcern`, contact inbox Jbuilder references, current Go owner files, source-ID/HMAC/idempotency/account-scope/serializer gaps, and focused exit gates. Verification for this docs-only checkpoint: `git diff --check`; next slice is `feat(contacts): align contact inbox creation`. - 2026-06-06: P3.23 nested contact inbox creation checkpoint prepared as `feat(contacts): align contact inbox creation`; audited Chatwoot nested contact inbox controller/specs, `ContactInboxBuilder`, `HmacConcern`, route `212`, and contact inbox Jbuilder serializer. GoChat now account-scopes nested contact/inbox creation, accepts raw JSON/form/query `inbox_id`, `source_id`, and `hmac_verified`, generates missing source IDs for API/WebWidget/Email/Sms/Whatsapp/Twilio channels, returns existing contact+inbox+source rows idempotently, persists HMAC verification on creation, and returns raw `{ source_id, inbox }`. Focused handler/service/repository tests passed; route parity and full `go test ./...` plus `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: P3.24 Slack integration checkpoint prepared as `feat(integrations): align slack parity`; audited Chatwoot Slack singleton routes/controller/request specs, `Integrations::Slack::HookBuilder`, `Integrations::Slack::ChannelBuilder`, app/hook Jbuilder partials, and reused dashboard integrations API/store. GoChat now exposes no-trailing and trailing Slack singleton routes, supports `PUT` and `PATCH` update, creates disabled Slack hooks from OAuth `code`, returns raw app payloads, fetches Slack private/public channels with cursor pagination, joins public channels, persists selected channel `reference_id/settings/status`, returns raw channel arrays, maps invalid channels to Chatwoot's `422 { error }`, and deletes with empty `200 OK`. Focused handler/service tests, route dump/parity regeneration (`939` routes; `379 exact`, `13 parameter-compatible`, `0 missing out of 392`), escalated full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. +- 2026-06-06: P3.25 Linear integration checkpoint prepared as `feat(integrations): align linear parity`; audited Chatwoot Linear controller/request specs, processor service, GraphQL client queries/mutations, activity-message service, account routes `365-373`, and reused dashboard Linear API. GoChat now exposes no-trailing and trailing Linear destroy plus teams/team_entities/create/link/unlink/search/linked routes, calls Linear GraphQL through fakeable clients, returns raw Chatwoot data payloads, maps provider failures to `422 { error }`, resolves conversations by account display ID, builds frontend conversation links, creates Linear activity messages for create/link/unlink, revokes tokens on delete, and returns empty `200 OK`. Focused Linear handler/service tests, route dump/parity regeneration (`939` routes; `387 exact`, `13 parameter-compatible`, `0 missing out of 400`), sandbox package tests, escalated full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index fa611828..084afa2f 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -71,6 +71,7 @@ DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/web_widget DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/web_widget/pre_chat_form DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/web_widget/theme_config DELETE /api/v1/accounts/:account_id/integrations/hooks/:id +DELETE /api/v1/accounts/:account_id/integrations/linear DELETE /api/v1/accounts/:account_id/integrations/linear/ DELETE /api/v1/accounts/:account_id/integrations/notion/ DELETE /api/v1/accounts/:account_id/integrations/shopify/ @@ -936,4 +937,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 938 +TOTAL: 939 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index a1e49ca3..69aa62e8 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 379 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 392 tracked critical routes. +Summary: 387 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 400 tracked critical routes. ## Missing Critical Routes @@ -77,6 +77,7 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/` | `api/v1/accounts/inboxes/assignment_policy#destroy` | `routes.rb:311` | exact | | DELETE | `/api/v1/accounts/:account_id/inboxes/:inbox_id/avatar` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/avatar` | `api/v1/accounts/inboxes#avatar` | `routes.rb:257` | exact | | DELETE | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#destroy` | `routes.rb:346` | exact | +| DELETE | `/api/v1/accounts/:account_id/integrations/linear` | `/api/v1/accounts/:account_id/integrations/linear` | `api/v1/accounts/integrations/linear#destroy` | `routes.rb:366` | exact | | DELETE | `/api/v1/accounts/:account_id/integrations/slack` | `/api/v1/accounts/:account_id/integrations/slack` | `api/v1/accounts/integrations/slack#destroy` | `routes.rb:350` | exact | | DELETE | `/api/v1/accounts/:account_id/labels/:tag_id` | `/api/v1/accounts/:account_id/labels/:tag_id` | `api/v1/accounts/labels#destroy` | `routes.rb:281` | exact | | DELETE | `/api/v1/accounts/:account_id/macros/:macro_id` | `/api/v1/accounts/:account_id/macros/:macro_id` | `api/v1/accounts/macros#destroy` | `routes.rb:118` | exact | @@ -174,6 +175,10 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/integrations/apps` | `/api/v1/accounts/:account_id/integrations/apps` | `api/v1/accounts/integrations/apps#index` | `routes.rb:345` | exact | | GET | `/api/v1/accounts/:account_id/integrations/apps/:id` | `/api/v1/accounts/:account_id/integrations/apps/:id` | `api/v1/accounts/integrations/apps#show` | `routes.rb:345` | exact | | GET | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#show` | `routes.rb:346` | exact | +| GET | `/api/v1/accounts/:account_id/integrations/linear/linked_issues` | `/api/v1/accounts/:account_id/integrations/linear/linked_issues` | `api/v1/accounts/integrations/linear#linked_issues` | `routes.rb:373` | exact | +| GET | `/api/v1/accounts/:account_id/integrations/linear/search_issue` | `/api/v1/accounts/:account_id/integrations/linear/search_issue` | `api/v1/accounts/integrations/linear#search_issue` | `routes.rb:372` | exact | +| GET | `/api/v1/accounts/:account_id/integrations/linear/team_entities` | `/api/v1/accounts/:account_id/integrations/linear/team_entities` | `api/v1/accounts/integrations/linear#team_entities` | `routes.rb:368` | exact | +| GET | `/api/v1/accounts/:account_id/integrations/linear/teams` | `/api/v1/accounts/:account_id/integrations/linear/teams` | `api/v1/accounts/integrations/linear#teams` | `routes.rb:367` | exact | | GET | `/api/v1/accounts/:account_id/integrations/slack/list_all_channels` | `/api/v1/accounts/:account_id/integrations/slack/list_all_channels` | `api/v1/accounts/integrations/slack#list_all_channels` | `routes.rb:352` | exact | | GET | `/api/v1/accounts/:account_id/labels/` | `/api/v1/accounts/:account_id/labels/` | `api/v1/accounts/labels#index` | `routes.rb:281` | exact | | GET | `/api/v1/accounts/:account_id/labels/:tag_id` | `/api/v1/accounts/:account_id/labels/:tag_id` | `api/v1/accounts/labels#show` | `routes.rb:281` | exact | @@ -341,6 +346,9 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/accounts/:account_id/integrations/dyte/create_a_meeting` | `/api/v1/accounts/:account_id/integrations/dyte/create_a_meeting` | `api/v1/accounts/integrations/dyte#create_a_meeting` | `routes.rb:357` | exact | | POST | `/api/v1/accounts/:account_id/integrations/hooks` | `/api/v1/accounts/:account_id/integrations/hooks` | `api/v1/accounts/integrations/hooks#create` | `routes.rb:346` | exact | | POST | `/api/v1/accounts/:account_id/integrations/hooks/:id/process_event` | `/api/v1/accounts/:account_id/integrations/hooks/:id/process_event` | `api/v1/accounts/integrations/hooks#process_event` | `routes.rb:348` | exact | +| POST | `/api/v1/accounts/:account_id/integrations/linear/create_issue` | `/api/v1/accounts/:account_id/integrations/linear/create_issue` | `api/v1/accounts/integrations/linear#create_issue` | `routes.rb:369` | exact | +| POST | `/api/v1/accounts/:account_id/integrations/linear/link_issue` | `/api/v1/accounts/:account_id/integrations/linear/link_issue` | `api/v1/accounts/integrations/linear#link_issue` | `routes.rb:370` | exact | +| POST | `/api/v1/accounts/:account_id/integrations/linear/unlink_issue` | `/api/v1/accounts/:account_id/integrations/linear/unlink_issue` | `api/v1/accounts/integrations/linear#unlink_issue` | `routes.rb:371` | exact | | POST | `/api/v1/accounts/:account_id/integrations/slack` | `/api/v1/accounts/:account_id/integrations/slack` | `api/v1/accounts/integrations/slack#create` | `routes.rb:350` | exact | | POST | `/api/v1/accounts/:account_id/labels/` | `/api/v1/accounts/:account_id/labels/` | `api/v1/accounts/labels#create` | `routes.rb:281` | exact | | POST | `/api/v1/accounts/:account_id/macros/` | `/api/v1/accounts/:account_id/macros/` | `api/v1/accounts/macros#create` | `routes.rb:118` | exact | diff --git a/internal/handler/api/v1/linear_integration_handler.go b/internal/handler/api/v1/linear_integration_handler.go index f879598d..30d07bc6 100644 --- a/internal/handler/api/v1/linear_integration_handler.go +++ b/internal/handler/api/v1/linear_integration_handler.go @@ -1,7 +1,9 @@ package v1 import ( + "errors" "net/http" + "strings" "github.com/gin-gonic/gin" @@ -33,7 +35,7 @@ func (h *LinearIntegrationHandler) Delete(c *gin.Context) { handleServiceError(c, svcErr) return } - response.OK(c, gin.H{"message": "Linear integration deleted"}) + c.Status(http.StatusOK) } // GetTeams retrieves available Linear teams. @@ -47,10 +49,10 @@ func (h *LinearIntegrationHandler) GetTeams(c *gin.Context) { teams, svcErr := h.svc.GetTeams(c.Request.Context(), accountID) if svcErr != nil { - handleServiceError(c, svcErr) + handleLinearServiceError(c, svcErr) return } - response.OK(c, teams) + c.JSON(http.StatusOK, teams) } // GetTeamEntities retrieves entities from a Linear team. @@ -62,12 +64,12 @@ func (h *LinearIntegrationHandler) GetTeamEntities(c *gin.Context) { return } - entities, svcErr := h.svc.GetTeamEntities(c.Request.Context(), accountID) + entities, svcErr := h.svc.GetTeamEntities(c.Request.Context(), accountID, c.Query("team_id")) if svcErr != nil { - handleServiceError(c, svcErr) + handleLinearServiceError(c, svcErr) return } - response.OK(c, entities) + c.JSON(http.StatusOK, entities) } // CreateIssue creates a Linear issue from a conversation. @@ -80,17 +82,17 @@ func (h *LinearIntegrationHandler) CreateIssue(c *gin.Context) { } var req service.CreateIssueRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := c.ShouldBind(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - result, svcErr := h.svc.CreateIssue(c.Request.Context(), accountID, req) + result, svcErr := h.svc.CreateIssue(c.Request.Context(), accountID, req, getUserID(c)) if svcErr != nil { - handleServiceError(c, svcErr) + handleLinearServiceError(c, svcErr) return } - response.OK(c, result) + c.JSON(http.StatusOK, result) } // LinkIssue links a Linear issue to a conversation. @@ -103,17 +105,17 @@ func (h *LinearIntegrationHandler) LinkIssue(c *gin.Context) { } var req service.LinkIssueRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := c.ShouldBind(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - result, svcErr := h.svc.LinkIssue(c.Request.Context(), accountID, req) + result, svcErr := h.svc.LinkIssue(c.Request.Context(), accountID, req, getUserID(c)) if svcErr != nil { - handleServiceError(c, svcErr) + handleLinearServiceError(c, svcErr) return } - response.OK(c, result) + c.JSON(http.StatusOK, result) } // UnlinkIssue unlinks a Linear issue from a conversation. @@ -126,17 +128,17 @@ func (h *LinearIntegrationHandler) UnlinkIssue(c *gin.Context) { } var req service.UnlinkIssueRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := c.ShouldBind(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - result, svcErr := h.svc.UnlinkIssue(c.Request.Context(), accountID, req) + result, svcErr := h.svc.UnlinkIssue(c.Request.Context(), accountID, req, getUserID(c)) if svcErr != nil { - handleServiceError(c, svcErr) + handleLinearServiceError(c, svcErr) return } - response.OK(c, result) + c.JSON(http.StatusOK, result) } // SearchIssue searches Linear issues. @@ -149,12 +151,16 @@ func (h *LinearIntegrationHandler) SearchIssue(c *gin.Context) { } query := c.Query("q") - issues, svcErr := h.svc.SearchIssue(c.Request.Context(), accountID, query) - if svcErr != nil { - handleServiceError(c, svcErr) + if strings.TrimSpace(query) == "" { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"}) return } - response.OK(c, issues) + issues, svcErr := h.svc.SearchIssue(c.Request.Context(), accountID, query) + if svcErr != nil { + handleLinearServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, issues) } // GetLinkedIssues retrieves Linear issues linked to a conversation. @@ -166,17 +172,22 @@ func (h *LinearIntegrationHandler) GetLinkedIssues(c *gin.Context) { return } - conversationID, _ := parseUintParam(c, "conversation_id") - issues, svcErr := h.svc.GetLinkedIssues(c.Request.Context(), accountID, conversationID) - if svcErr != nil { - handleServiceError(c, svcErr) + conversationID, err := parseOptionalUintQueryParam(c, "conversation_id") + if err != nil || conversationID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id") return } - response.OK(c, issues) + issues, svcErr := h.svc.GetLinkedIssues(c.Request.Context(), accountID, conversationID) + if svcErr != nil { + handleLinearServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, issues) } // RegisterLinearIntegrationRoutes registers Linear integration routes. func RegisterLinearIntegrationRoutes(g *gin.RouterGroup, h *LinearIntegrationHandler) { + g.DELETE("/linear", h.Delete) linear := g.Group("/linear") { linear.DELETE("/", h.Delete) @@ -189,3 +200,12 @@ func RegisterLinearIntegrationRoutes(g *gin.RouterGroup, h *LinearIntegrationHan linear.GET("/linked_issues", h.GetLinkedIssues) } } + +func handleLinearServiceError(c *gin.Context, err error) { + var providerErr *service.LinearProviderError + if errors.As(err, &providerErr) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": providerErr.Message}) + return + } + handleServiceError(c, err) +} diff --git a/internal/handler/api/v1/linear_integration_handler_test.go b/internal/handler/api/v1/linear_integration_handler_test.go index b226a27e..c3a9bbed 100644 --- a/internal/handler/api/v1/linear_integration_handler_test.go +++ b/internal/handler/api/v1/linear_integration_handler_test.go @@ -181,6 +181,20 @@ func TestLinearIntegration_SearchIssue_BadAccountID(t *testing.T) { assert.Contains(t, errBody["message"], "invalid account_id") } +func TestLinearIntegration_SearchIssue_BlankQuery(t *testing.T) { + r := setupLinearIntegrationRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/linear/search_issue", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "Specify search string with parameter q", resp["error"]) +} + func TestLinearIntegration_GetLinkedIssues_BadAccountID(t *testing.T) { r := setupLinearIntegrationRouter() diff --git a/internal/repository/integration_hook_repo.go b/internal/repository/integration_hook_repo.go index eeff3b31..f2772bec 100644 --- a/internal/repository/integration_hook_repo.go +++ b/internal/repository/integration_hook_repo.go @@ -14,6 +14,11 @@ type IntegrationHookRepo struct { db *gorm.DB } +// DB returns the underlying gorm.DB for services that need adjacent models. +func (r *IntegrationHookRepo) DB() *gorm.DB { + return r.db +} + // NewIntegrationHookRepo creates a new IntegrationHook repository. func NewIntegrationHookRepo(db *gorm.DB) *IntegrationHookRepo { return &IntegrationHookRepo{db: db} diff --git a/internal/service/linear_notion_integration_service.go b/internal/service/linear_notion_integration_service.go index df330d09..04ad1a92 100644 --- a/internal/service/linear_notion_integration_service.go +++ b/internal/service/linear_notion_integration_service.go @@ -1,35 +1,74 @@ package service import ( + "bytes" "context" "encoding/json" + "errors" "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) +// LinearProviderError mirrors Chatwoot's `{ error: ... }`, 422 provider failures. +type LinearProviderError struct { + Message interface{} +} + +func (e *LinearProviderError) Error() string { + if e == nil || e.Message == nil { + return "linear provider error" + } + if msg, ok := e.Message.(string); ok { + return msg + } + b, err := json.Marshal(e.Message) + if err != nil { + return fmt.Sprint(e.Message) + } + return string(b) +} + // LinearIntegrationService implements Linear integration business logic. // Reference: Chatwoot Integrations::LinearController // Linear integration creates and links issues from conversations. type LinearIntegrationService struct { - hookRepo *repository.IntegrationHookRepo + hookRepo *repository.IntegrationHookRepo + conversationRepo *repository.ConversationRepo + messageRepo *repository.MessageRepo + userRepo *repository.UserRepo + client *linearAPIClient } // NewLinearIntegrationService creates a new LinearIntegrationService. func NewLinearIntegrationService(hookRepo *repository.IntegrationHookRepo) *LinearIntegrationService { - return &LinearIntegrationService{hookRepo: hookRepo} + svc := &LinearIntegrationService{hookRepo: hookRepo, client: newLinearAPIClientFromEnv()} + if hookRepo != nil && hookRepo.DB() != nil { + db := hookRepo.DB() + svc.conversationRepo = repository.NewConversationRepo(db) + svc.messageRepo = repository.NewMessageRepo(db) + svc.userRepo = repository.NewUserRepo(db) + } + return svc } // Delete removes a Linear integration hook for an account. func (s *LinearIntegrationService) Delete(ctx context.Context, accountID uint) error { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) + hooks, err := s.findLinearHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return fmt.Errorf("Linear integration not found for account %d", accountID) } for _, hook := range hooks { + _ = s.client.revokeToken(ctx, hook.AccessToken, linearRefreshToken(hook.Settings)) if err := s.hookRepo.Delete(ctx, hook.ID); err != nil { return fmt.Errorf("failed to delete Linear integration: %w", err) } @@ -42,123 +81,508 @@ func (s *LinearIntegrationService) Delete(ctx context.Context, accountID uint) e // GetTeams retrieves available Linear teams (proxy to Linear API). // GET /api/v1/accounts/:account_id/integrations/linear/teams func (s *LinearIntegrationService) GetTeams(ctx context.Context, accountID uint) ([]map[string]interface{}, error) { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) - if err != nil || len(hooks) == 0 { - return nil, fmt.Errorf("Linear integration not found for account %d", accountID) + client, err := s.linearClient(ctx, accountID) + if err != nil { + return nil, err } - - var settings model.LinearSettings - if err := json.Unmarshal(hooks[0].Settings, &settings); err != nil { - return nil, fmt.Errorf("failed to unmarshal Linear settings: %w", err) + data, err := client.teams(ctx) + if err != nil { + return nil, err } - - // In production, this would call the Linear API. applogger.L().Infof("Listing Linear teams for account=%d", accountID) - return []map[string]interface{}{ - {"id": settings.TeamID, "name": settings.TeamName}, - }, nil + return nodesFromPath(data, "teams"), nil } // GetTeamEntities retrieves entities from a Linear team. // GET /api/v1/accounts/:account_id/integrations/linear/team_entities -func (s *LinearIntegrationService) GetTeamEntities(ctx context.Context, accountID uint) ([]map[string]interface{}, error) { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) - if err != nil || len(hooks) == 0 { - return nil, fmt.Errorf("Linear integration not found for account %d", accountID) +func (s *LinearIntegrationService) GetTeamEntities(ctx context.Context, accountID uint, teamID string) (map[string]interface{}, error) { + client, err := s.linearClient(ctx, accountID) + if err != nil { + return nil, err + } + data, err := client.teamEntities(ctx, teamID) + if err != nil { + return nil, err } - applogger.L().Infof("Listing Linear team entities for account=%d", accountID) - return []map[string]interface{}{}, nil + return map[string]interface{}{ + "users": nodesFromPath(data, "users"), + "projects": nodesFromPath(data, "projects"), + "states": nodesFromPath(data, "workflowStates"), + "labels": nodesFromPath(data, "issueLabels"), + }, nil } // CreateIssueRequest is the DTO for creating a Linear issue. type CreateIssueRequest struct { - Title string `json:"title" validate:"required"` - Description string `json:"description,omitempty"` - TeamID string `json:"team_id,omitempty"` + Title string `json:"title" form:"title"` + Description string `json:"description,omitempty" form:"description"` + TeamID string `json:"team_id,omitempty" form:"team_id"` + ProjectID string `json:"project_id,omitempty" form:"project_id"` + ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"` + AssigneeID string `json:"assignee_id,omitempty" form:"assignee_id"` + Priority interface{} `json:"priority,omitempty" form:"priority"` + StateID string `json:"state_id,omitempty" form:"state_id"` + LabelIDs []string `json:"label_ids,omitempty" form:"label_ids[]"` } // CreateIssue creates a Linear issue from a conversation. // POST /api/v1/accounts/:account_id/integrations/linear/create_issue -func (s *LinearIntegrationService) CreateIssue(ctx context.Context, accountID uint, req CreateIssueRequest) (map[string]interface{}, error) { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) - if err != nil || len(hooks) == 0 { - return nil, fmt.Errorf("Linear integration not found for account %d", accountID) +func (s *LinearIntegrationService) CreateIssue(ctx context.Context, accountID uint, req CreateIssueRequest, userID ...uint) (map[string]interface{}, error) { + conversation, err := s.findConversation(ctx, accountID, req.ConversationID) + if err != nil { + return nil, err } - + client, err := s.linearClient(ctx, accountID) + if err != nil { + return nil, err + } + user := s.findUser(ctx, optionalUint(userID)) + data, err := client.createIssue(ctx, req, user) + if err != nil { + return nil, err + } + issue := map[string]interface{}{ + "id": nestedString(data, "issueCreate", "issue", "id"), + "title": nestedString(data, "issueCreate", "issue", "title"), + "identifier": nestedString(data, "issueCreate", "issue", "identifier"), + } + s.createLinearActivity(ctx, conversation, user, "created", fmt.Sprint(issue["identifier"])) applogger.L().Infof("Creating Linear issue for account=%d: title=%s", accountID, req.Title) - // In production, this would call the Linear API to create an issue. - return map[string]interface{}{ - "status": "created", - "title": req.Title, - "team_id": req.TeamID, - }, nil + return issue, nil } // LinkIssueRequest is the DTO for linking a Linear issue to a conversation. type LinkIssueRequest struct { - IssueID string `json:"issue_id" validate:"required"` - ConversationID uint `json:"conversation_id,omitempty"` + IssueID string `json:"issue_id" form:"issue_id"` + ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"` + Title string `json:"title,omitempty" form:"title"` } // LinkIssue links a Linear issue to a conversation. // POST /api/v1/accounts/:account_id/integrations/linear/link_issue -func (s *LinearIntegrationService) LinkIssue(ctx context.Context, accountID uint, req LinkIssueRequest) (map[string]interface{}, error) { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) - if err != nil || len(hooks) == 0 { - return nil, fmt.Errorf("Linear integration not found for account %d", accountID) +func (s *LinearIntegrationService) LinkIssue(ctx context.Context, accountID uint, req LinkIssueRequest, userID ...uint) (map[string]interface{}, error) { + conversation, err := s.findConversation(ctx, accountID, req.ConversationID) + if err != nil { + return nil, err } - + client, err := s.linearClient(ctx, accountID) + if err != nil { + return nil, err + } + user := s.findUser(ctx, optionalUint(userID)) + link := linearConversationLink(accountID, conversation) + data, err := client.linkIssue(ctx, link, req.IssueID, req.Title, user) + if err != nil { + return nil, err + } + result := map[string]interface{}{ + "id": req.IssueID, + "link": link, + "link_id": nestedString(data, "attachmentLinkURL", "attachment", "id"), + } + s.createLinearActivity(ctx, conversation, user, "linked", req.IssueID) applogger.L().Infof("Linking Linear issue %s for account=%d", req.IssueID, accountID) - return map[string]interface{}{ - "status": "linked", - "issue_id": req.IssueID, - }, nil + return result, nil } // UnlinkIssueRequest is the DTO for unlinking a Linear issue from a conversation. type UnlinkIssueRequest struct { - IssueID string `json:"issue_id" validate:"required"` - ConversationID uint `json:"conversation_id,omitempty"` + IssueID string `json:"issue_id" form:"issue_id"` + LinkID string `json:"link_id" form:"link_id"` + ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"` } // UnlinkIssue unlinks a Linear issue from a conversation. // POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue -func (s *LinearIntegrationService) UnlinkIssue(ctx context.Context, accountID uint, req UnlinkIssueRequest) (map[string]interface{}, error) { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) - if err != nil || len(hooks) == 0 { - return nil, fmt.Errorf("Linear integration not found for account %d", accountID) +func (s *LinearIntegrationService) UnlinkIssue(ctx context.Context, accountID uint, req UnlinkIssueRequest, userID ...uint) (map[string]interface{}, error) { + conversation, err := s.findConversation(ctx, accountID, req.ConversationID) + if err != nil { + return nil, err } - + client, err := s.linearClient(ctx, accountID) + if err != nil { + return nil, err + } + if _, err := client.unlinkIssue(ctx, req.LinkID); err != nil { + return nil, err + } + user := s.findUser(ctx, optionalUint(userID)) + s.createLinearActivity(ctx, conversation, user, "unlinked", req.IssueID) applogger.L().Infof("Unlinking Linear issue %s for account=%d", req.IssueID, accountID) return map[string]interface{}{ - "status": "unlinked", - "issue_id": req.IssueID, + "link_id": req.LinkID, }, nil } // SearchIssue searches Linear issues. // GET /api/v1/accounts/:account_id/integrations/linear/search_issue func (s *LinearIntegrationService) SearchIssue(ctx context.Context, accountID uint, query string) ([]map[string]interface{}, error) { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) - if err != nil || len(hooks) == 0 { - return nil, fmt.Errorf("Linear integration not found for account %d", accountID) + client, err := s.linearClient(ctx, accountID) + if err != nil { + return nil, err + } + data, err := client.searchIssue(ctx, query) + if err != nil { + return nil, err } - applogger.L().Infof("Searching Linear issues for account=%d, query=%s", accountID, query) - return []map[string]interface{}{}, nil + return nodesFromPath(data, "searchIssues"), nil } // GetLinkedIssues retrieves Linear issues linked to a conversation. // GET /api/v1/accounts/:account_id/integrations/linear/linked_issues func (s *LinearIntegrationService) GetLinkedIssues(ctx context.Context, accountID uint, conversationID uint) ([]map[string]interface{}, error) { - hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) + conversation, err := s.findConversation(ctx, accountID, conversationID) + if err != nil { + return nil, err + } + client, err := s.linearClient(ctx, accountID) + if err != nil { + return nil, err + } + data, err := client.linkedIssues(ctx, linearConversationLink(accountID, conversation)) + if err != nil { + return nil, err + } + applogger.L().Infof("Listing linked Linear issues for account=%d, conversation=%d", accountID, conversationID) + return nodesFromPath(data, "attachmentsForURL"), nil +} + +func (s *LinearIntegrationService) findLinearHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) { + hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "linear") + if err != nil { + return nil, err + } + if len(hooks) > 0 { + return hooks, nil + } + return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear) +} + +func (s *LinearIntegrationService) linearClient(ctx context.Context, accountID uint) (*linearBoundClient, error) { + hooks, err := s.findLinearHooks(ctx, accountID) if err != nil || len(hooks) == 0 { return nil, fmt.Errorf("Linear integration not found for account %d", accountID) } + token := strings.TrimSpace(hooks[0].AccessToken) + if token == "" { + var settings model.LinearSettings + _ = json.Unmarshal(hooks[0].Settings, &settings) + token = strings.TrimSpace(settings.AccessToken) + } + if token == "" { + return nil, &LinearProviderError{Message: "Missing Credentials"} + } + return &linearBoundClient{client: s.client, token: token}, nil +} - applogger.L().Infof("Listing linked Linear issues for account=%d, conversation=%d", accountID, conversationID) - return []map[string]interface{}{}, nil +func (s *LinearIntegrationService) findConversation(ctx context.Context, accountID, displayID uint) (*model.Conversation, error) { + if s.conversationRepo == nil || displayID == 0 { + return nil, fmt.Errorf("conversation not found") + } + return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, displayID) +} + +func (s *LinearIntegrationService) findUser(ctx context.Context, userID uint) *model.User { + if s.userRepo == nil || userID == 0 { + return nil + } + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return nil + } + return user +} + +func (s *LinearIntegrationService) createLinearActivity(ctx context.Context, conversation *model.Conversation, user *model.User, action, issueID string) { + if s.messageRepo == nil || conversation == nil || user == nil || issueID == "" { + return + } + content := fmt.Sprintf("Linear issue %s was %s by %s", issueID, action, user.Name) + message := &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + MessageType: "activity", + ContentType: "text", + Status: "sent", + Content: content, + } + if err := s.messageRepo.Create(ctx, message); err != nil { + applogger.L().Warnf("failed to create Linear activity message: %v", err) + } +} + +func linearConversationLink(accountID uint, conversation *model.Conversation) string { + displayID := conversation.ID + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + displayID = *conversation.DisplayID + } + base := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") + return fmt.Sprintf("%s/app/accounts/%d/conversations/%d", base, accountID, displayID) +} + +func optionalUint(values []uint) uint { + if len(values) == 0 { + return 0 + } + return values[0] +} + +func linearRefreshToken(settingsJSON []byte) string { + var settings map[string]interface{} + if err := json.Unmarshal(settingsJSON, &settings); err != nil { + return "" + } + if token, ok := settings["refresh_token"].(string); ok { + return token + } + return "" +} + +type linearAPIClient struct { + graphqlURL string + revokeURL string + httpClient *http.Client +} + +type linearBoundClient struct { + client *linearAPIClient + token string +} + +func newLinearAPIClientFromEnv() *linearAPIClient { + baseURL := strings.TrimRight(os.Getenv("LINEAR_API_BASE"), "/") + if baseURL == "" { + baseURL = "https://api.linear.app" + } + return &linearAPIClient{ + graphqlURL: baseURL + "/graphql", + revokeURL: baseURL + "/oauth/revoke", + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *linearBoundClient) teams(ctx context.Context) (map[string]interface{}, error) { + return c.postGraphQL(ctx, `query { teams { nodes { id name } } }`) +} + +func (c *linearBoundClient) teamEntities(ctx context.Context, teamID string) (map[string]interface{}, error) { + if strings.TrimSpace(teamID) == "" { + return nil, &LinearProviderError{Message: "Missing team id"} + } + query := fmt.Sprintf(`query { users { nodes { id name } } projects { nodes { id name } } workflowStates(filter: { team: { id: { eq: %s } } }) { nodes { id name } } issueLabels(filter: { team: { id: { eq: %s } } }) { nodes { id name } } }`, graphqlValue(teamID), graphqlValue(teamID)) + return c.postGraphQL(ctx, query) +} + +func (c *linearBoundClient) createIssue(ctx context.Context, req CreateIssueRequest, user *model.User) (map[string]interface{}, error) { + input := map[string]interface{}{ + "title": req.Title, + "teamId": req.TeamID, + "description": req.Description, + "assigneeId": req.AssigneeID, + "priority": req.Priority, + "labelIds": req.LabelIDs, + "projectId": req.ProjectID, + "stateId": req.StateID, + } + if user != nil && user.Name != "" { + input["createAsUser"] = user.Name + if user.AvatarURL != "" { + input["displayIconUrl"] = user.AvatarURL + } + } + query := fmt.Sprintf(`mutation { issueCreate(input: { %s }) { success issue { id title identifier } } }`, graphqlInput(input)) + return c.postGraphQL(ctx, query) +} + +func (c *linearBoundClient) linkIssue(ctx context.Context, link, issueID, title string, user *model.User) (map[string]interface{}, error) { + if strings.TrimSpace(link) == "" { + return nil, &LinearProviderError{Message: "Missing link"} + } + if strings.TrimSpace(issueID) == "" { + return nil, &LinearProviderError{Message: "Missing issue id"} + } + parts := []string{ + "url: " + graphqlValue(link), + "issueId: " + graphqlValue(issueID), + "title: " + graphqlValue(title), + } + if user != nil && user.Name != "" { + parts = append(parts, "createAsUser: "+graphqlValue(user.Name)) + if user.AvatarURL != "" { + parts = append(parts, "displayIconUrl: "+graphqlValue(user.AvatarURL)) + } + } + query := fmt.Sprintf(`mutation { attachmentLinkURL(%s) { success attachment { id } } }`, strings.Join(parts, ", ")) + return c.postGraphQL(ctx, query) +} + +func (c *linearBoundClient) unlinkIssue(ctx context.Context, linkID string) (map[string]interface{}, error) { + if strings.TrimSpace(linkID) == "" { + return nil, &LinearProviderError{Message: "Missing link id"} + } + query := fmt.Sprintf(`mutation { attachmentDelete(id: %s) { success } }`, graphqlValue(linkID)) + return c.postGraphQL(ctx, query) +} + +func (c *linearBoundClient) searchIssue(ctx context.Context, term string) (map[string]interface{}, error) { + if strings.TrimSpace(term) == "" { + return nil, &LinearProviderError{Message: "Missing search term"} + } + query := fmt.Sprintf(`query { searchIssues(term: %s) { nodes { id title description identifier url state { name color } } } }`, graphqlValue(term)) + return c.postGraphQL(ctx, query) +} + +func (c *linearBoundClient) linkedIssues(ctx context.Context, link string) (map[string]interface{}, error) { + if strings.TrimSpace(link) == "" { + return nil, &LinearProviderError{Message: "Missing link"} + } + query := fmt.Sprintf(`query { attachmentsForURL(url: %s) { nodes { id title issue { id identifier title description priority createdAt url assignee { name avatarUrl } state { name color } labels { nodes { id name color description } } } } } }`, graphqlValue(link)) + return c.postGraphQL(ctx, query) +} + +func (c *linearBoundClient) postGraphQL(ctx context.Context, query string) (map[string]interface{}, error) { + return c.client.postGraphQL(ctx, c.token, query) +} + +func (c *linearAPIClient) postGraphQL(ctx context.Context, token, query string) (map[string]interface{}, error) { + body, err := json.Marshal(map[string]string{"query": query}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var payload map[string]interface{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || payload["data"] == nil { + if len(payload) > 0 { + return nil, &LinearProviderError{Message: payload} + } + return nil, &LinearProviderError{Message: strings.TrimSpace(string(raw))} + } + data, ok := payload["data"].(map[string]interface{}) + if !ok { + return nil, &LinearProviderError{Message: payload} + } + return data, nil +} + +func (c *linearAPIClient) revokeToken(ctx context.Context, accessToken, refreshToken string) error { + token := strings.TrimSpace(refreshToken) + tokenType := "refresh_token" + if token == "" { + token = strings.TrimSpace(accessToken) + tokenType = "access_token" + } + if token == "" { + return nil + } + form := url.Values{} + form.Set("token", token) + form.Set("token_type_hint", tokenType) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.revokeURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return errors.New("linear revoke failed") + } + return nil +} + +func nodesFromPath(data map[string]interface{}, key string) []map[string]interface{} { + container, _ := data[key].(map[string]interface{}) + rawNodes, _ := container["nodes"].([]interface{}) + nodes := make([]map[string]interface{}, 0, len(rawNodes)) + for _, raw := range rawNodes { + if item, ok := raw.(map[string]interface{}); ok { + nodes = append(nodes, item) + } + } + return nodes +} + +func nestedString(data map[string]interface{}, keys ...string) string { + var current interface{} = data + for _, key := range keys { + m, ok := current.(map[string]interface{}) + if !ok { + return "" + } + current = m[key] + } + if s, ok := current.(string); ok { + return s + } + return "" +} + +func graphqlInput(input map[string]interface{}) string { + ordered := []string{"title", "teamId", "description", "assigneeId", "priority", "labelIds", "projectId", "stateId", "createAsUser", "displayIconUrl"} + parts := make([]string, 0, len(input)) + for _, key := range ordered { + value, ok := input[key] + if !ok || isBlankGraphQLValue(value) { + continue + } + parts = append(parts, fmt.Sprintf("%s: %s", key, graphqlValue(value))) + } + return strings.Join(parts, ", ") +} + +func isBlankGraphQLValue(value interface{}) bool { + switch v := value.(type) { + case nil: + return true + case string: + return v == "" + case []string: + return len(v) == 0 + default: + return false + } +} + +func graphqlValue(value interface{}) string { + switch v := value.(type) { + case string: + b, _ := json.Marshal(v) + return string(b) + case []string: + items := make([]string, 0, len(v)) + for _, item := range v { + items = append(items, graphqlValue(item)) + } + return "[" + strings.Join(items, ", ") + "]" + default: + return fmt.Sprint(v) + } } // ---- Notion Integration Service ---- diff --git a/internal/service/linear_notion_integration_service_test.go b/internal/service/linear_notion_integration_service_test.go index 78ccdc36..5ddd7a63 100644 --- a/internal/service/linear_notion_integration_service_test.go +++ b/internal/service/linear_notion_integration_service_test.go @@ -1,8 +1,12 @@ package service import ( + "bytes" "context" "encoding/json" + "io" + "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -29,6 +33,11 @@ func setupLinearNotionTestDB(t *testing.T) *gorm.DB { require.NoError(t, db.AutoMigrate( &model.Account{}, + &model.User{}, + &model.Contact{}, + &model.Inbox{}, + &model.Conversation{}, + &model.Message{}, &model.IntegrationHook{}, ), "failed to auto-migrate models") @@ -59,6 +68,7 @@ func seedLinearHook(db *gorm.DB, t *testing.T, accountID uint) uint { hook := &model.IntegrationHook{ AccountID: accountID, + AppID: "linear", HookType: model.HookTypeLinear, Status: model.HookStatusActive, AccessToken: "lin_token_123", @@ -68,6 +78,67 @@ func seedLinearHook(db *gorm.DB, t *testing.T, accountID uint) uint { return hook.ID } +func seedLinearConversation(db *gorm.DB, t *testing.T, accountID uint) *model.Conversation { + t.Helper() + inbox := &model.Inbox{AccountID: accountID, Name: "Support", ChannelType: "web_widget"} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: accountID, Name: "Jane"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(42) + conversation := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conversation).Error) + return conversation +} + +func seedLinearUser(db *gorm.DB, t *testing.T, accountID uint) *model.User { + t.Helper() + user := &model.User{AccountID: accountID, Name: "Agent Smith", Email: "agent@example.test", Password: "secret"} + require.NoError(t, db.Create(user).Error) + return user +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func fakeLinearHTTPClient(t *testing.T) *http.Client { + t.Helper() + return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth/revoke" { + return fakeHTTPResponse(http.StatusOK, `{}`), nil + } + var payload map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + query := payload["query"] + switch { + case strings.Contains(query, "teams") && !strings.Contains(query, "workflowStates"): + return fakeHTTPResponse(http.StatusOK, `{"data":{"teams":{"nodes":[{"id":"team-1","name":"Engineering"}]}}}`), nil + case strings.Contains(query, "workflowStates"): + return fakeHTTPResponse(http.StatusOK, `{"data":{"users":{"nodes":[{"id":"user-1","name":"User One"}]},"projects":{"nodes":[{"id":"project-1","name":"Project One"}]},"workflowStates":{"nodes":[{"id":"state-1","name":"Started"}]},"issueLabels":{"nodes":[{"id":"label-1","name":"Bug"}]}}}`), nil + case strings.Contains(query, "issueCreate"): + return fakeHTTPResponse(http.StatusOK, `{"data":{"issueCreate":{"issue":{"id":"issue-1","title":"Bug in login flow","identifier":"ENG-123"}}}}`), nil + case strings.Contains(query, "attachmentLinkURL"): + return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentLinkURL":{"attachment":{"id":"attachment-1"}}}}`), nil + case strings.Contains(query, "attachmentDelete"): + return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentDelete":{"success":true}}}`), nil + case strings.Contains(query, "searchIssues"): + return fakeHTTPResponse(http.StatusOK, `{"data":{"searchIssues":{"nodes":[{"id":"issue-1","title":"Sample Issue","identifier":"ENG-123"}]}}}`), nil + case strings.Contains(query, "attachmentsForURL"): + return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentsForURL":{"nodes":[{"id":"attachment-1","title":"Sample Issue","issue":{"id":"issue-1","identifier":"ENG-123"}}]}}}`), nil + default: + return fakeHTTPResponse(http.StatusUnprocessableEntity, `{"errors":[{"message":"unknown query"}]}`), nil + } + })} +} + +func fakeHTTPResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(body)), + } +} + func seedNotionHook(db *gorm.DB, t *testing.T, accountID uint) uint { t.Helper() settings := model.NotionSettings{ @@ -93,6 +164,7 @@ func setupLinearService(t *testing.T) (*LinearIntegrationService, *gorm.DB) { db := setupLinearNotionTestDB(t) hookRepo := repository.NewIntegrationHookRepo(db) svc := NewLinearIntegrationService(hookRepo) + svc.client = &linearAPIClient{graphqlURL: "https://linear.example.test/graphql", revokeURL: "https://linear.example.test/oauth/revoke", httpClient: fakeLinearHTTPClient(t)} return svc, db } @@ -155,31 +227,54 @@ func TestLinearIntegrationService_GetTeams_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "Linear integration not found") } -func TestLinearIntegrationService_CreateIssue_Success(t *testing.T) { +func TestLinearIntegrationService_GetTeamEntities_Success(t *testing.T) { svc, db := setupLinearService(t) accountID := seedLinearNotionAccount(db, t) seedLinearHook(db, t, accountID) + entities, err := svc.GetTeamEntities(context.Background(), accountID, "team-1") + assert.NoError(t, err) + assert.Equal(t, "User One", entities["users"].([]map[string]interface{})[0]["name"]) + assert.Equal(t, "Project One", entities["projects"].([]map[string]interface{})[0]["name"]) + assert.Equal(t, "Started", entities["states"].([]map[string]interface{})[0]["name"]) + assert.Equal(t, "Bug", entities["labels"].([]map[string]interface{})[0]["name"]) +} + +func TestLinearIntegrationService_CreateIssue_Success(t *testing.T) { + svc, db := setupLinearService(t) + accountID := seedLinearNotionAccount(db, t) + seedLinearHook(db, t, accountID) + conversation := seedLinearConversation(db, t, accountID) + user := seedLinearUser(db, t, accountID) + req := CreateIssueRequest{ - Title: "Bug in login flow", - Description: "Users cannot log in after password reset", - TeamID: "team-1", + Title: "Bug in login flow", + Description: "Users cannot log in after password reset", + TeamID: "team-1", + ConversationID: *conversation.DisplayID, } - result, err := svc.CreateIssue(context.Background(), accountID, req) + result, err := svc.CreateIssue(context.Background(), accountID, req, user.ID) assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, "Bug in login flow", result["title"]) + assert.Equal(t, "ENG-123", result["identifier"]) + + var message model.Message + require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error) + assert.Equal(t, "Linear issue ENG-123 was created by Agent Smith", message.Content) } func TestLinearIntegrationService_CreateIssue_NotFound(t *testing.T) { svc, db := setupLinearService(t) accountID := seedLinearNotionAccount(db, t) + conversation := seedLinearConversation(db, t, accountID) // No Linear hook seeded req := CreateIssueRequest{ - Title: "Some issue", - TeamID: "team-1", + Title: "Some issue", + TeamID: "team-1", + ConversationID: *conversation.DisplayID, } result, err := svc.CreateIssue(context.Background(), accountID, req) @@ -192,27 +287,37 @@ func TestLinearIntegrationService_LinkIssue_Success(t *testing.T) { svc, db := setupLinearService(t) accountID := seedLinearNotionAccount(db, t) seedLinearHook(db, t, accountID) + conversation := seedLinearConversation(db, t, accountID) + user := seedLinearUser(db, t, accountID) + t.Setenv("FRONTEND_URL", "https://app.example.test") req := LinkIssueRequest{ IssueID: "LIN-42", - ConversationID: 10, + ConversationID: *conversation.DisplayID, + Title: "Sample Issue", } - result, err := svc.LinkIssue(context.Background(), accountID, req) + result, err := svc.LinkIssue(context.Background(), accountID, req, user.ID) assert.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, "linked", result["status"]) - assert.Equal(t, "LIN-42", result["issue_id"]) + assert.Equal(t, "LIN-42", result["id"]) + assert.Equal(t, "attachment-1", result["link_id"]) + assert.Equal(t, "https://app.example.test/app/accounts/1/conversations/42", result["link"]) + + var message model.Message + require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error) + assert.Equal(t, "Linear issue LIN-42 was linked by Agent Smith", message.Content) } func TestLinearIntegrationService_LinkIssue_NotFound(t *testing.T) { svc, db := setupLinearService(t) accountID := seedLinearNotionAccount(db, t) + conversation := seedLinearConversation(db, t, accountID) // No Linear hook seeded req := LinkIssueRequest{ IssueID: "LIN-42", - ConversationID: 10, + ConversationID: *conversation.DisplayID, } result, err := svc.LinkIssue(context.Background(), accountID, req) @@ -221,6 +326,46 @@ func TestLinearIntegrationService_LinkIssue_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "Linear integration not found") } +func TestLinearIntegrationService_UnlinkIssue_Success(t *testing.T) { + svc, db := setupLinearService(t) + accountID := seedLinearNotionAccount(db, t) + seedLinearHook(db, t, accountID) + conversation := seedLinearConversation(db, t, accountID) + user := seedLinearUser(db, t, accountID) + + result, err := svc.UnlinkIssue(context.Background(), accountID, UnlinkIssueRequest{IssueID: "ENG-123", LinkID: "attachment-1", ConversationID: *conversation.DisplayID}, user.ID) + assert.NoError(t, err) + assert.Equal(t, "attachment-1", result["link_id"]) + + var message model.Message + require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error) + assert.Equal(t, "Linear issue ENG-123 was unlinked by Agent Smith", message.Content) +} + +func TestLinearIntegrationService_SearchIssue_Success(t *testing.T) { + svc, db := setupLinearService(t) + accountID := seedLinearNotionAccount(db, t) + seedLinearHook(db, t, accountID) + + issues, err := svc.SearchIssue(context.Background(), accountID, "Sample") + assert.NoError(t, err) + assert.Len(t, issues, 1) + assert.Equal(t, "Sample Issue", issues[0]["title"]) +} + +func TestLinearIntegrationService_GetLinkedIssues_UsesConversationDisplayID(t *testing.T) { + svc, db := setupLinearService(t) + accountID := seedLinearNotionAccount(db, t) + seedLinearHook(db, t, accountID) + conversation := seedLinearConversation(db, t, accountID) + t.Setenv("FRONTEND_URL", "https://app.example.test") + + issues, err := svc.GetLinkedIssues(context.Background(), accountID, *conversation.DisplayID) + assert.NoError(t, err) + assert.Len(t, issues, 1) + assert.Equal(t, "Sample Issue", issues[0]["title"]) +} + // ======================================== // NotionIntegrationService tests // ======================================== @@ -247,4 +392,4 @@ func TestNotionIntegrationService_Delete_NotFound(t *testing.T) { err := svc.Delete(context.Background(), accountID) assert.Error(t, err) assert.Contains(t, err.Error(), "Notion integration not found") -} \ No newline at end of file +}