diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index 0eeab55074..a6c74969aa 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -67,17 +67,21 @@ Every tool requires `runtimeRoles: ["project_manager"]` and is enabled by defaul ### Sprint issue imports -`manage_sprints` action `import_issues` accepts `provider` values `github`, `gitlab`, `jira`, `notion`, `asana`, and `linear`. +`manage_sprints` action `import_issues` accepts `provider` values `github`, `gitlab`, `jira`, `notion`, `asana`, `linear`, `miro`, `lucid`, `figma`, and `mural`. - GitHub and GitLab require saved effective `git.githubToken` / `git.gitlabToken` settings. - Jira requires saved Jira host, email, API token, and usually a default project key. - Notion requires `notion.apiToken`; optional `databaseId` narrows page/database imports. - Asana requires `asana.apiToken` plus `workspaceId` for workspace search or `providerProjectId` for project task fallback. - Linear requires `linear.apiToken`; optional `teamId`, `teamKey`, and `providerProjectId` narrow issue search. +- Miro requires `miro.apiToken`; `boardId` identifies the board and `itemTypes` can narrow readable item imports. +- Lucid requires `lucid.apiToken`; `documentId` identifies a Lucidchart/Lucidspark document and `search` can discover documents. +- Figma/FigJam requires `figma.apiToken` plus `fileKey` or explicit file keys in `externalIds`. +- Mural requires `mural.apiToken` plus `workspaceId` for listing or `muralId` / the shared `mural.boardId` setting for a specific mural. Mural API support is beta/limited. -Common import fields include `search`, `state`, `status`, `labels`, `assignee`, `issueKeys`, `issueNumbers`, `issueRefs`, `externalIds`, `includeConversation`, `limit`, `sprintId`, `attachToSprint`, and `planAfterImport`. Use `externalIds` for explicit Notion page/database ids, Asana task gids, and Linear issue ids or identifiers. +Common import fields include `search`, `state`, `status`, `labels`, `assignee`, `issueKeys`, `issueNumbers`, `issueRefs`, `externalIds`, `boardId`, `documentId`, `fileKey`, `workspaceId`, `muralId`, `itemTypes`, `includeConversation`, `limit`, `sprintId`, `attachToSprint`, and `planAfterImport`. Use `externalIds` for explicit Notion page/database ids, Asana task gids, Linear issue ids or identifiers, Miro item ids, Lucid document ids, Figma file keys, and Mural ids. -Notion, Asana, and Linear imports are read-only: Code UX searches, fetches prompt context, attaches linked-source records, and can plan from imported scope, but it does not close, complete, archive, or transition those provider items. +External imports are read-only: Code UX searches, fetches prompt context, attaches linked-source records, and can plan from imported scope, but it does not close, complete, archive, transition, write comments, or mutate provider items, boards, documents, files, or murals. For the full per-action payloads and return shapes, see [Management actions](/docs/developer-management-actions). diff --git a/docs-web/content/docs/user-dashboard-sprints.mdx b/docs-web/content/docs/user-dashboard-sprints.mdx index 49ead53fed..df367b2654 100644 --- a/docs-web/content/docs/user-dashboard-sprints.mdx +++ b/docs-web/content/docs/user-dashboard-sprints.mdx @@ -83,19 +83,13 @@ You can also import linked sprint scope from configured external systems. GitHub The Import menu also exposes **Notion**, **Asana**, and **Linear**. Each opens a provider-specific search modal, then imports selected results through the shared linked issue composer flow. Selected items appear as linked cards below the sprint prompt and are merged into the prompt markdown when the sprint is submitted. -Notion, Asana, and Linear imports are API-backed and read-only: +Notion, Asana, Linear, Miro, Lucid, Figma/FigJam, and Mural imports are API-backed and read-only. Notion uses `notion.apiToken` plus an optional `databaseId`, Asana uses `asana.apiToken` plus `workspaceId` or project fallback, Linear uses `linear.apiToken` plus optional `teamId`, `teamKey`, or project filters, Miro uses `miro.apiToken` plus `boardId`, Lucid uses `lucid.apiToken` plus `documentId` or document search, Figma/FigJam uses `figma.apiToken` plus `fileKey`, and Mural uses `mural.apiToken` plus `workspaceId` or `muralId`. These importers can search, fetch readable body/comment context where providers expose it, attach linked scope to a sprint, and plan from that imported scope; they do not archive pages, complete tasks, transition issues, close external items, write comments, or mutate boards, documents, files, or murals. Mural public API support is beta/limited, so imported Mural scope may contain only metadata and readable content available to the token. -- **Notion** requires `notion.apiToken`. `databaseId` is optional and narrows page/database search. Advanced filters can import exact page or database IDs. -- **Asana** requires `asana.apiToken` plus `workspaceId` or `projectId`. The modal supports text search, status, labels, assignee, exact task GIDs, result limit, and workspace/project overrides. -- **Linear** requires `linear.apiToken`. `teamId`, `teamKey`, and `projectId` are optional narrowing defaults. The modal supports text search, workflow state, status/state type, labels, assignee, exact issue IDs or identifiers, result limit, and team/project overrides. - -These importers can search, fetch readable body/comment context, attach linked scope to a sprint, and plan from that imported scope. They do not archive Notion pages, complete Asana tasks, transition Linear issues, close external items, or create special imported task modes. - -Each result card has an **Append conversation** toggle, and the footer can apply that setting to all selected results. When enabled, Asana task stories and Linear comments are included in the sprint prompt context. Missing settings and provider API errors are shown in the import modal error panel with the backend message so you can fix the integration configuration directly. +Notion, Asana, and Linear imports also keep the shared linked issue composer flow. Each result card has an **Append conversation** toggle, and the footer can apply that setting to all selected results. When enabled, Asana task stories and Linear comments are included in the sprint prompt context. Missing settings and provider API errors are shown in the import modal error panel with the backend message so you can fix the integration configuration directly. The shared settings model also supports Notion, Asana, Linear, Miro, Lucid, Figma, and Mural connection blocks. These provider blocks store enablement, token/secret, base URL, workspace/team/project identifiers, database/board/document/file identifiers, and a default search limit. They are saved and resolved through the same system → project → sprint settings hierarchy as other sprint integrations. -Linked sprint records preserve numeric issue numbers for GitHub, GitLab, and Jira. Non-numeric external objects are stored with an `externalId`, `sourceKind`, and `sourceProvider`, so Notion pages/databases, Asana tasks, Linear issues, and future board/document/file/canvas importers can attach scope without inventing fake issue numbers. +Linked sprint records preserve numeric issue numbers for GitHub, GitLab, and Jira. Non-numeric external objects are stored with an `externalId`, `sourceKind`, and `sourceProvider`, so Notion pages/databases, Asana tasks, Linear issues, Miro boards/items, Lucid documents, Figma/FigJam files, and Mural canvases can attach scope without inventing fake issue numbers. ## Sprint settings overrides diff --git a/docs/dashboard/sprint-imports.md b/docs/dashboard/sprint-imports.md index 3c545370d0..a82f45b96d 100644 --- a/docs/dashboard/sprint-imports.md +++ b/docs/dashboard/sprint-imports.md @@ -1,8 +1,8 @@ # Sprint Imports -Sprint imports support production paths from the Sprints page and MCP: structured markdown bundles, GitHub/GitLab issue imports, Jira issue imports, and read-only linked-scope imports from Notion, Asana, and Linear. +Sprint imports support production paths from the Sprints page and MCP: structured markdown bundles, GitHub/GitLab issue imports, Jira issue imports, read-only linked-scope imports from Notion, Asana, and Linear, and read-only collaborative canvas imports from Miro, Lucid, Figma/FigJam, and Mural. -Internal MCP clients use the same importer services through `manage_sprints` action `import_issues`. For payload examples covering search-only imports, assigned-work searches, explicit Jira keys, explicit GitHub/GitLab issue numbers, explicit Notion/Asana/Linear external IDs, sprint attachment, and plan-after-import flows, see [MCP Tools and Contracts: `manage_sprints import_issues`](../mcp/tools-and-contracts.md#manage_sprints-import_issues). +Internal MCP clients use the same importer services through `manage_sprints` action `import_issues`. For payload examples covering search-only imports, assigned-work searches, explicit Jira keys, explicit GitHub/GitLab issue numbers, explicit external IDs, canvas identifiers, sprint attachment, and plan-after-import flows, see [MCP Tools and Contracts: `manage_sprints import_issues`](../mcp/tools-and-contracts.md#manage_sprints-import_issues). ## Markdown Import @@ -74,15 +74,23 @@ Issue import uses the saved integration tokens: - Notion: system/project effective `notion.apiToken`; `databaseId` can narrow search or explicitly import a database. - Asana: system/project effective `asana.apiToken`; workspace search uses `workspaceId`, while project fallback uses `projectId`. - Linear: system/project effective `linear.apiToken`; `teamId`, `teamKey`, and `projectId` can narrow issue search. +- Miro: system/project effective `miro.apiToken`; `boardId` identifies the board used for readable board items, and `itemTypes` can narrow returned board item types. +- Lucid: system/project effective `lucid.apiToken`; `documentId` identifies a Lucidchart or Lucidspark document for readable contents, while `search` can discover documents. +- Figma/FigJam: system/project effective `figma.apiToken`; `fileKey` is required because the Figma API does not expose a general file search endpoint for this importer. +- Mural: system/project effective `mural.apiToken`; `workspaceId` lists workspace murals, while `muralId` or the shared `mural.boardId` setting fetches a specific mural. When the GitHub token is empty, GitHub issue search, issue context loading, and auto-close fail with a token-required error. Code UX does not fall back to local `gh` or `glab` CLI authentication for dashboard or MCP importer workflows; Docker auth-copy mount settings help worker containers, but issue search, explicit import, linked sprint attachment, planning imports, and close operations need saved GitHub/GitLab tokens. -Notion, Asana, and Linear importer workflows use direct provider APIs through `fetch` and require saved tokens before any network request is made. They are read/attach only: Code UX searches, fetches readable context, persists local linked-source records, enriches sprint prompts, and can plan from that imported scope, but it does not archive Notion pages, complete Asana tasks, transition Linear issues, or close those external items. +External importer workflows use direct provider APIs through `fetch` and require saved tokens before any network request is made. They are read/attach only: Code UX searches, fetches readable context, persists local linked-source records, enriches sprint prompts, and can plan from that imported scope, but it does not archive Notion pages, complete Asana tasks, transition Linear issues, mutate canvas boards/files/documents/murals, write comments, or close those external items. Provider-specific search behavior: - Notion uses `POST https://api.notion.com/v1/search` with `Authorization: Bearer` and `Notion-Version`, maps pages and databases, and reads page/database block children into prompt markdown when blocks are readable. - Asana uses `GET https://app.asana.com/api/1.0/workspaces/{workspace_gid}/tasks/search` for workspace task search and falls back to project tasks when a project id is supplied. When conversation context is requested, task stories/comments are appended to prompt markdown. - Linear uses `POST https://api.linear.app/graphql` for issue search/filter queries and explicit issue fetches. Results include description, labels, state, team/project, assignee, URL, and comments when conversation context is requested. +- Miro uses `GET https://api.miro.com/v2/boards` for board discovery and `GET https://api.miro.com/v2/boards/{boardId}/items` for readable board items. Results map board/item ids, titles, item types, URLs, modified timestamps, and readable text/data fields into prompt markdown. +- Lucid uses `POST https://api.lucid.co/documents/search` for document search and `GET https://api.lucid.co/v1/documents/{id}/contents` for readable Lucidchart/Lucidspark contents. Requests use `Authorization: Bearer` and `Lucid-Api-Version: 1`. +- Figma/FigJam uses `GET https://api.figma.com/v1/files/{fileKey}` and, when `includeConversation` is true, `GET https://api.figma.com/v1/files/{fileKey}/comments` with `X-Figma-Token`. Results include file name, last modified timestamp, top-level pages/nodes, and comments. +- Mural uses `https://app.mural.co/api/public/v1`, `GET /workspaces/{workspaceId}/murals` for listing, and `GET /murals/{muralId}` for mural metadata/content available to the token. Mural public API support is beta/limited, so imported prompt context may contain only metadata and readable content the token can access. ## Notion, Asana, And Linear Scope Import @@ -102,7 +110,7 @@ Result cards support multi-select, `Select all visible`, `Clear selection`, per- ## Project-Management And Canvas Integration Settings -Code UX carries shared typed settings for additional importer providers: Notion, Asana, Linear, Miro, Lucid, Figma, and Mural. Notion, Asana, and Linear have API-backed sprint importers today. Miro, Lucid, Figma, and Mural currently have settings and linked-source persistence contracts but no API-backed sprint importer in this flow yet. Jira continues to use the existing `jira` settings block, and GitHub/GitLab continue to use `git.githubToken` and `git.gitlabToken`. +Code UX carries shared typed settings for additional importer providers: Notion, Asana, Linear, Miro, Lucid, Figma, and Mural. These providers have API-backed read-only sprint importers. Jira continues to use the existing `jira` settings block, and GitHub/GitLab continue to use `git.githubToken` and `git.gitlabToken`. Each new provider settings block stores only strings and a bounded numeric search limit: @@ -122,6 +130,15 @@ Each new provider settings block stores only strings and a bounded numeric searc The fields are intentionally generic across project-management and collaborative-canvas systems. Provider-specific importer UI can use only the identifiers it needs, while reset, save, sanitize, and effective-settings preview paths preserve the complete block. +Accepted canvas identifiers: +- `boardId`: Miro board id. For Mural, the existing shared `boardId` setting is also accepted as the default mural id until a dedicated settings field exists. +- `documentId`: Lucidchart or Lucidspark document id. +- `fileKey`: Figma or FigJam file key from the file URL. +- `workspaceId`: Mural workspace id for listing murals; also used by Asana for task search. +- `muralId`: explicit Mural id in route/MCP payloads. +- `externalIds`: explicit provider object ids. For canvas imports this can hold Miro item ids, Lucid document ids, Figma file keys, or Mural ids. +- `itemTypes`: optional Miro item type filters such as `sticky_note` or `text`. + ## Linked Source Persistence Persisted sprint scope can represent both numeric repository issues and non-numeric external objects. diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index 9db1b3faa6..9d7883bb09 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -437,7 +437,7 @@ For sprint create/update calls: ### `manage_sprints import_issues` -`manage_sprints` action `import_issues` is the MCP contract for GitHub, GitLab, Jira, Notion, Asana, and Linear importer access. Internal MCP clients use it for search-only discovery, assigned-work searches, explicit ticket or external-object imports, linked sprint issue attachment, and optional planning after import. +`manage_sprints` action `import_issues` is the MCP contract for GitHub, GitLab, Jira, Notion, Asana, Linear, Miro, Lucid, Figma/FigJam, and Mural importer access. Internal MCP clients use it for search-only discovery, assigned-work searches, explicit ticket or external-object imports, linked sprint issue attachment, and optional planning after import. Provider requirements: - GitHub imports require a saved effective `git.githubToken` in system or project settings. @@ -446,10 +446,14 @@ Provider requirements: - Notion imports require a saved effective `notion.apiToken`. `databaseId` can narrow page search or explicitly import a database. - Asana imports require a saved effective `asana.apiToken` plus either `workspaceId` for workspace task search or `providerProjectId` / `asana.projectId` for project task fallback. - Linear imports require a saved effective `linear.apiToken`. `teamId`, `teamKey`, and `providerProjectId` can narrow issue search when configured or supplied. +- Miro imports require a saved effective `miro.apiToken`. `boardId` identifies a board for board item imports, and `itemTypes` can narrow item types. +- Lucid imports require a saved effective `lucid.apiToken`. `documentId` identifies a Lucidchart/Lucidspark document; `search` can discover documents. +- Figma/FigJam imports require a saved effective `figma.apiToken` plus `fileKey` or explicit file keys in `externalIds`. +- Mural imports require a saved effective `mural.apiToken` plus `workspaceId` for workspace mural search or `muralId` / `mural.boardId` for a specific mural. Mural API support is beta/limited and may return only metadata and readable content available to the token. - Importer workflows do not fall back to local CLI authentication. A locally authenticated `gh`, `glab`, or Git remote is not enough for MCP issue search, explicit import, sprint attachment, or planning import paths. -- Notion, Asana, and Linear imports are read/attach only. Code UX does not transition, complete, close, or otherwise mutate those external work items. +- External imports are read/attach only. Code UX does not transition, complete, close, write back, comment on, or otherwise mutate imported work items, boards, documents, files, or murals. -Search/import callers can provide `provider` (`github`, `gitlab`, `jira`, `notion`, `asana`, or `linear`), `repository`, `hostDomain`, `workspaceId`, `providerProjectId`, `externalProjectId`, `asanaProjectId`, `linearProjectId`, `teamId`, `teamKey`, `databaseId`, `projectKey`, `search`, `state`, `status`, `labels`, `assignee`, `assigneeText`, `issueKeys`, `issueNumbers`, `issueRefs`, `externalIds`, `includeConversation`, `limit`, and optional sprint attachment fields. `sprintId` and `attachToSprint` represent sprint attachment intent. `planAfterImport`, `autoStart`, `planningAgentPresetId`, `replan`, and `overrides` represent optional planning intent after import. +Search/import callers can provide `provider` (`github`, `gitlab`, `jira`, `notion`, `asana`, `linear`, `miro`, `lucid`, `figma`, or `mural`), `repository`, `hostDomain`, `workspaceId`, `providerProjectId`, `externalProjectId`, `asanaProjectId`, `linearProjectId`, `teamId`, `teamKey`, `databaseId`, `boardId`, `documentId`, `fileKey`, `muralId`, `itemTypes`, `projectKey`, `search`, `state`, `status`, `labels`, `assignee`, `assigneeText`, `issueKeys`, `issueNumbers`, `issueRefs`, `externalIds`, `includeConversation`, `limit`, and optional sprint attachment fields. `sprintId` and `attachToSprint` represent sprint attachment intent. `planAfterImport`, `autoStart`, `planningAgentPresetId`, `replan`, and `overrides` represent optional planning intent after import. Search-only GitHub example: @@ -539,6 +543,56 @@ Search-only Linear example: } ``` +Miro board item example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "miro", + "boardId": "miro-board-id", + "itemTypes": ["sticky_note", "text"], + "limit": 25 +} +``` + +Lucid document search example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "lucid", + "search": "architecture", + "limit": 10 +} +``` + +Figma/FigJam file example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "figma", + "fileKey": "figma-file-key", + "includeConversation": true +} +``` + +Mural workspace example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "mural", + "workspaceId": "mural-workspace-id", + "search": "planning", + "limit": 10 +} +``` + Explicit Jira key example: ```json @@ -592,6 +646,18 @@ Explicit external object example: } ``` +Explicit canvas object example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "figma", + "fileKey": "figma-file-key", + "includeConversation": true +} +``` + Attach imported issues to an existing sprint: ```json @@ -632,13 +698,13 @@ Attach imported issues and run planning after the sprint goal is enriched: Result shape: - Search mode returns `mode: "search"` and populates `searchedIssues` with lightweight normalized issue summaries. -- Explicit-reference mode returns `mode: "explicit"` and populates `importedContexts` with prompt contexts that can include full issue body and conversation text. For Notion, Asana, and Linear, explicit imports use `externalIds`. +- Explicit-reference mode returns `mode: "explicit"` and populates `importedContexts` with prompt contexts that can include full issue body and conversation text. For Notion, Asana, Linear, and canvas providers, explicit imports use `externalIds` or provider-specific identifiers such as `databaseId`, `boardId`, `documentId`, `fileKey`, and `muralId`. - When `sprintId` is supplied and `attachToSprint` is not `false`, the response includes persisted `linkedIssues` metadata records and the updated `sprint`. - When `planAfterImport` is `true`, the response includes the optional `planning` result from sprint planning. `planAfterImport` requires `sprintId` because planning runs against an existing sprint. Persistence and prompt behavior: - `issueKeys` and Jira-style refs such as `OPS-123` resolve through Jira. `issueNumbers` and refs such as `#42` or `!42` resolve through GitHub/GitLab when `repository` and `hostDomain` are provided or inferable from the project. -- `externalIds` resolve through Notion page/database fetches, Asana task fetches, or Linear issue fetches. Search results and explicit contexts normalize to linked-source records with `externalId`, `sourceKind`, stable display keys, source URL, preview text, metadata, and prompt markdown when readable provider content is available. +- `externalIds` resolve through Notion page/database fetches, Asana task fetches, Linear issue fetches, Miro board/item fetches, Lucid document content fetches, Figma/FigJam file fetches, or Mural metadata/content fetches. Search results and explicit contexts normalize to linked-source records with `externalId`, `sourceKind`, stable display keys, source URL, preview text, metadata, and prompt markdown when readable provider content is available. - Full issue body and comment/conversation text are merged into the sprint goal under `## Linked Issues` before planning so the Planning agent receives the complete context. - Linked issue persistence stores metadata only: provider, repository or project key, issue key/number, title, labels, assignees, status, source URL, and related tracking fields. Full remote issue bodies and comments remain prompt-only data and are not stored in linked issue rows. - Issue search and import are not destructive actions. Sprint deletion remains approval-gated. diff --git a/src/contracts/mcp-tool-definitions.ts b/src/contracts/mcp-tool-definitions.ts index 10469467e7..c9d71e3550 100644 --- a/src/contracts/mcp-tool-definitions.ts +++ b/src/contracts/mcp-tool-definitions.ts @@ -71,7 +71,7 @@ export const TOOL_DEFINITIONS = [ autoStart: { type: "boolean", description: "Optional for plan and for import_issues when planAfterImport is true." }, replan: { type: "boolean", description: "Optional for plan." }, search: { type: "string", description: "Optional issue search query for import_issues." }, - provider: { type: "string", enum: ["github", "gitlab", "jira", "notion", "asana", "linear"], description: "Optional issue provider for import_issues." }, + provider: { type: "string", enum: ["github", "gitlab", "jira", "notion", "asana", "linear", "miro", "lucid", "figma", "mural"], description: "Optional issue provider for import_issues." }, repository: { type: "string", description: "Optional repository owner/name or project path for import_issues." }, hostDomain: { type: "string", description: "Optional issue host domain for import_issues, such as github.com, gitlab.com, or a Jira site domain." }, workspaceId: { type: "string", description: "Optional provider workspace id for Notion, Asana, and Linear imports." }, @@ -82,6 +82,11 @@ export const TOOL_DEFINITIONS = [ teamId: { type: "string", description: "Optional Linear team id for import_issues." }, teamKey: { type: "string", description: "Optional Linear team key for import_issues." }, databaseId: { type: "string", description: "Optional Notion database id for import_issues." }, + boardId: { type: "string", description: "Optional Miro board id for board/item imports." }, + documentId: { type: "string", description: "Optional Lucid document id for Lucidchart or Lucidspark imports." }, + fileKey: { type: "string", description: "Optional Figma or FigJam file key for file imports." }, + muralId: { type: "string", description: "Optional Mural id for Mural imports." }, + itemTypes: { type: "array", items: { type: "string" }, description: "Optional Miro item type filters for board item imports." }, projectKey: { type: "string", description: "Optional Jira project key for import_issues." }, state: { type: "string", description: "Optional GitHub/GitLab issue state (open, closed, all) or Linear workflow state for import_issues." }, labels: { type: "array", items: { type: "string" }, description: "Optional labels to filter or identify imported issues." }, @@ -91,7 +96,7 @@ export const TOOL_DEFINITIONS = [ issueKeys: { type: "array", items: { type: "string" }, description: "Optional explicit Jira issue keys for import_issues." }, issueNumbers: { type: "array", items: { type: "number" }, description: "Optional explicit GitHub/GitLab issue numbers for import_issues." }, issueRefs: { type: "array", items: { type: "string" }, description: "Optional explicit issue references for import_issues, such as #123 or OPS-42." }, - externalIds: { type: "array", items: { type: "string" }, description: "Optional explicit Notion page/database ids, Asana task gids, or Linear issue ids/identifiers for import_issues." }, + externalIds: { type: "array", items: { type: "string" }, description: "Optional explicit Notion page/database ids, Asana task gids, Linear issue ids/identifiers, Miro item ids, Lucid document ids, Figma file keys, or Mural ids for import_issues." }, includeConversation: { type: "boolean", description: "Optional import_issues flag controlling whether issue comments/conversation are included." }, attachToSprint: { type: "boolean", description: "Optional import_issues flag controlling whether matching issues should be attached to sprintId when provided." }, planAfterImport: { type: "boolean", description: "Optional import_issues flag requesting sprint planning after imported issues are attached." }, diff --git a/src/contracts/project-management-types.ts b/src/contracts/project-management-types.ts index 51f3b91e09..86691c1f94 100644 --- a/src/contracts/project-management-types.ts +++ b/src/contracts/project-management-types.ts @@ -68,9 +68,15 @@ export interface RepositoryIssueSearchInput { hostDomain?: string; workspaceId?: string; projectId?: string; + providerProjectId?: string; teamId?: string; teamKey?: string; databaseId?: string; + boardId?: string; + documentId?: string; + fileKey?: string; + muralId?: string; + itemTypes?: string[]; projectKey?: string; search?: string; state?: RepositoryIssueSearchState; diff --git a/src/mcp/management/sprint-actions.ts b/src/mcp/management/sprint-actions.ts index 6e4b536d1d..6d52e7fcfa 100644 --- a/src/mcp/management/sprint-actions.ts +++ b/src/mcp/management/sprint-actions.ts @@ -98,6 +98,11 @@ function hasSearchFilters(input: IssueSearchInput): boolean { || input.teamId || input.teamKey || input.databaseId + || input.boardId + || input.documentId + || input.fileKey + || input.muralId + || input.itemTypes?.length || input.projectKey || input.state || input.status @@ -118,13 +123,23 @@ function hasSearchFilters(input: IssueSearchInput): boolean { } function hasExplicitIssueReferences(input: IssueSearchInput): boolean { - return Boolean(input.issueKeys?.length || input.issueNumbers?.length || input.issueRefs?.length || input.externalIds?.length); + return Boolean( + input.issueKeys?.length + || input.issueNumbers?.length + || input.issueRefs?.length + || input.externalIds?.length + || input.boardId + || input.documentId + || input.fileKey + || input.muralId + || input.databaseId + ); } function buildImportIssueSearchInput(payload: Record): IssueSearchInput { const input: IssueSearchInput = { search: readString(payload, "search"), - provider: parseOptionalEnumStrict(payload, "provider", ["github", "gitlab", "jira", "notion", "asana", "linear"] as const), + provider: parseOptionalEnumStrict(payload, "provider", ["github", "gitlab", "jira", "notion", "asana", "linear", "miro", "lucid", "figma", "mural"] as const), repository: readString(payload, "repository"), hostDomain: readString(payload, "hostDomain"), workspaceId: readString(payload, "workspaceId"), @@ -132,6 +147,11 @@ function buildImportIssueSearchInput(payload: Record): IssueSea teamId: readString(payload, "teamId"), teamKey: readString(payload, "teamKey"), databaseId: readString(payload, "databaseId"), + boardId: readString(payload, "boardId"), + documentId: readString(payload, "documentId"), + fileKey: readString(payload, "fileKey"), + muralId: readString(payload, "muralId"), + itemTypes: readStringArray(payload, "itemTypes"), projectKey: readString(payload, "projectKey"), state: parseImportState(payload), status: parseImportStatus(payload), @@ -146,7 +166,7 @@ function buildImportIssueSearchInput(payload: Record): IssueSea issueNumbers: readNumberArray(payload, "issueNumbers"), issueRefs: readStringArray(payload, "issueRefs"), externalIds: readStringArray(payload, "externalIds"), - includeConversation: payload.includeConversation === false ? false : undefined, + includeConversation: payload.includeConversation === true ? true : payload.includeConversation === false ? false : undefined, createdAfter: readString(payload, "createdAfter"), createdBefore: readString(payload, "createdBefore"), updatedAfter: readString(payload, "updatedAfter"), @@ -160,7 +180,7 @@ function buildImportIssueSearchInput(payload: Record): IssueSea function parseImportState(payload: Record): IssueSearchInput["state"] { const provider = typeof payload.provider === "string" ? payload.provider.trim().toLowerCase() : ""; - if (provider === "linear" || provider === "asana" || provider === "notion") { + if (provider === "linear" || provider === "asana" || provider === "notion" || provider === "miro" || provider === "lucid" || provider === "figma" || provider === "mural") { return readString(payload, "state"); } return parseOptionalEnumStrict(payload, "state", ["open", "closed", "all"] as const); @@ -168,7 +188,7 @@ function parseImportState(payload: Record): IssueSearchInput["s function parseImportStatus(payload: Record): IssueSearchInput["status"] { const provider = typeof payload.provider === "string" ? payload.provider.trim().toLowerCase() : ""; - if (provider === "linear" || provider === "asana" || provider === "notion") { + if (provider === "linear" || provider === "asana" || provider === "notion" || provider === "miro" || provider === "lucid" || provider === "figma" || provider === "mural") { return readString(payload, "status"); } return parseOptionalEnumStrict(payload, "status", ["open", "in_progress", "done", "all"] as const); diff --git a/src/server/sprint-routes.ts b/src/server/sprint-routes.ts index 32696f9154..b7a41b010d 100644 --- a/src/server/sprint-routes.ts +++ b/src/server/sprint-routes.ts @@ -272,6 +272,11 @@ function parseRepositoryIssueSearchQuery(query: Record): IssueS teamId: parseTrimmedQueryString(query.teamId, "teamId"), teamKey: parseTrimmedQueryString(query.teamKey, "teamKey"), databaseId: parseTrimmedQueryString(query.databaseId, "databaseId"), + boardId: parseTrimmedQueryString(query.boardId, "boardId"), + documentId: parseTrimmedQueryString(query.documentId, "documentId"), + fileKey: parseTrimmedQueryString(query.fileKey, "fileKey"), + muralId: parseTrimmedQueryString(query.muralId, "muralId"), + itemTypes: parseIssueLabels(query.itemTypes), projectKey: parseTrimmedQueryString(query.projectKey, "projectKey"), search: parseTrimmedQueryString(query.search, "search"), state: parseRepositoryIssueState(query.state, provider), @@ -344,8 +349,8 @@ function parseRepositoryProvider(value: unknown): RepositoryIssueSearchInput["pr if (!trimmed) { return undefined; } - if (trimmed !== "github" && trimmed !== "gitlab" && trimmed !== "jira" && trimmed !== "notion" && trimmed !== "asana" && trimmed !== "linear") { - throw new Error("Invalid value for provider. Must be one of: github, gitlab, jira, notion, asana, linear"); + if (trimmed !== "github" && trimmed !== "gitlab" && trimmed !== "jira" && trimmed !== "notion" && trimmed !== "asana" && trimmed !== "linear" && trimmed !== "miro" && trimmed !== "lucid" && trimmed !== "figma" && trimmed !== "mural") { + throw new Error("Invalid value for provider. Must be one of: github, gitlab, jira, notion, asana, linear, miro, lucid, figma, mural"); } return trimmed; } @@ -355,7 +360,7 @@ function parseRepositoryIssueState(value: unknown, provider?: RepositoryIssueSea if (!trimmed) { return undefined; } - if (provider === "notion" || provider === "asana" || provider === "linear") { + if (provider === "notion" || provider === "asana" || provider === "linear" || provider === "miro" || provider === "lucid" || provider === "figma" || provider === "mural") { return trimmed; } if (trimmed !== "open" && trimmed !== "closed" && trimmed !== "all") { @@ -369,7 +374,7 @@ function parseImportStatus(value: unknown, provider?: RepositoryIssueSearchInput if (!trimmed) { return undefined; } - if (provider === "notion" || provider === "asana" || provider === "linear") { + if (provider === "notion" || provider === "asana" || provider === "linear" || provider === "miro" || provider === "lucid" || provider === "figma" || provider === "mural") { return trimmed; } if (trimmed !== "open" && trimmed !== "in_progress" && trimmed !== "done" && trimmed !== "all") { diff --git a/src/services/figma-api-client.ts b/src/services/figma-api-client.ts new file mode 100644 index 0000000000..c348d0cc90 --- /dev/null +++ b/src/services/figma-api-client.ts @@ -0,0 +1,136 @@ +export interface FigmaSearchInput { + fileKey?: string; + externalIds?: string[]; + includeConversation?: boolean; + limit: number; + baseUrl?: string; +} + +export interface FigmaFileItem { + key: string; + title: string; + url: string; + bodyMarkdown: string; + conversationMarkdown: string; + updatedAt: string | null; + metadata: Record; +} + +const FIGMA_API_BASE_URL = "https://api.figma.com/v1"; + +interface FigmaFileResponse { + name?: string; + lastModified?: string | null; + document?: FigmaNode; +} + +interface FigmaNode { + id?: string; + name?: string; + type?: string; + characters?: string; + children?: FigmaNode[]; +} + +interface FigmaCommentsResponse { + comments?: FigmaComment[]; +} + +interface FigmaComment { + id?: string; + message?: string; + created_at?: string | null; + resolved_at?: string | null; + file_key?: string; + user?: { handle?: string; id?: string }; + client_meta?: Record; +} + +export async function getFiles(token: string, input: FigmaSearchInput): Promise { + const fileKeys = uniqueStrings([...(input.externalIds || []), input.fileKey || ""]).slice(0, input.limit); + const items: FigmaFileItem[] = []; + for (const fileKey of fileKeys) { + items.push(await getFile(token, fileKey, input)); + } + return items; +} + +export async function getFile(token: string, fileKey: string, input: Omit): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl); + const file = await requestFigma(`${baseUrl}/files/${encodeURIComponent(fileKey)}`, token); + const comments = input.includeConversation === true + ? (await requestFigma(`${baseUrl}/files/${encodeURIComponent(fileKey)}/comments`, token)).comments || [] + : []; + const pages = file.document?.children || []; + return { + key: fileKey, + title: file.name || `Figma file ${fileKey}`, + url: `https://www.figma.com/file/${fileKey}`, + bodyMarkdown: formatFigmaNodesMarkdown(pages), + conversationMarkdown: formatCommentsMarkdown(comments), + updatedAt: file.lastModified || null, + metadata: { + pageCount: pages.length, + pages: pages.slice(0, 50).map((page) => ({ + id: page.id || null, + name: page.name || null, + type: page.type || null, + childCount: page.children?.length || 0, + })), + }, + }; +} + +async function requestFigma(url: string, token: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "X-Figma-Token": token, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Figma API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + return await response.json() as T; +} + +function formatFigmaNodesMarkdown(pages: FigmaNode[]): string { + return pages.slice(0, 50).map((page) => { + const childLines = (page.children || []).slice(0, 25).map((child) => { + const text = child.characters?.trim(); + return `- ${child.name || child.id || "Untitled node"}${child.type ? ` (${child.type})` : ""}${text ? `: ${text}` : ""}`; + }); + return [`## ${page.name || page.id || "Untitled page"}`, ...childLines].join("\n"); + }).join("\n\n").trim(); +} + +function formatCommentsMarkdown(comments: FigmaComment[]): string { + return comments + .filter((comment) => Boolean(comment.message?.trim())) + .map((comment, index) => { + const author = comment.user?.handle || comment.user?.id || "unknown"; + const meta = [ + `Comment ${index + 1}`, + `@${author}`, + comment.created_at || "", + comment.resolved_at ? `resolved ${comment.resolved_at}` : "", + ].filter(Boolean).join(" - "); + return `##### ${meta}\n\n${comment.message?.trim() || "_No comment body provided._"}`; + }) + .join("\n\n"); +} + +function normalizeBaseUrl(baseUrl?: string): string { + return (baseUrl?.trim() || FIGMA_API_BASE_URL).replace(/\/+$/, ""); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function truncatePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact; +} diff --git a/src/services/lucid-api-client.ts b/src/services/lucid-api-client.ts new file mode 100644 index 0000000000..0a45aa5589 --- /dev/null +++ b/src/services/lucid-api-client.ts @@ -0,0 +1,181 @@ +export interface LucidSearchInput { + documentId?: string; + search?: string; + externalIds?: string[]; + limit: number; + baseUrl?: string; +} + +export interface LucidDocumentItem { + id: string; + title: string; + url: string; + bodyMarkdown: string; + createdAt: string | null; + updatedAt: string | null; + metadata: Record; +} + +const LUCID_API_BASE_URL = "https://api.lucid.co"; + +interface LucidSearchResponse { + documents?: LucidDocumentSummary[]; + data?: LucidDocumentSummary[]; + results?: LucidDocumentSummary[]; +} + +interface LucidDocumentSummary { + id?: string; + documentId?: string; + title?: string; + name?: string; + url?: string; + editUrl?: string; + viewUrl?: string; + lastModified?: string | null; + modified?: string | null; + created?: string | null; + product?: string | null; +} + +export async function searchDocuments(token: string, input: LucidSearchInput): Promise { + const ids = uniqueStrings([...(input.externalIds || []), input.documentId || ""]); + if (ids.length > 0) { + return getDocuments(token, ids, input); + } + + const baseUrl = normalizeBaseUrl(input.baseUrl); + const payload = await requestLucid(`${baseUrl}/documents/search`, token, { + method: "POST", + body: { + query: input.search || "", + limit: input.limit, + }, + }); + return getSearchDocuments(payload) + .filter((document): document is LucidDocumentSummary & { id: string } => typeof getDocumentId(document) === "string") + .slice(0, input.limit) + .map((document) => toDocumentItem(document, "")); +} + +export async function getDocuments(token: string, documentIds: string[], input: Omit): Promise { + const items: LucidDocumentItem[] = []; + const baseUrl = normalizeBaseUrl(input.baseUrl); + for (const documentId of uniqueStrings(documentIds).slice(0, input.limit)) { + const contents = await requestLucid(`${baseUrl}/v1/documents/${encodeURIComponent(documentId)}/contents`, token); + items.push(toDocumentItem({ id: documentId }, readableTextFromUnknown(contents), contents)); + } + return items; +} + +async function requestLucid(url: string, token: string, options: { + method?: string; + body?: Record; +} = {}): Promise { + const response = await fetch(url, { + method: options.method || "GET", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "Lucid-Api-Version": "1", + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Lucid API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + return await response.json() as T; +} + +function toDocumentItem(summary: LucidDocumentSummary, bodyMarkdown: string, contents?: unknown): LucidDocumentItem { + const id = getDocumentId(summary) || "unknown-document"; + const contentSummary = summarizeLucidContents(contents); + return { + id, + title: summary.title || summary.name || contentSummary.title || `Lucid document ${id}`, + url: summary.url || summary.editUrl || summary.viewUrl || `https://lucid.app/documents#/documents/${id}`, + bodyMarkdown: bodyMarkdown || contentSummary.bodyMarkdown, + createdAt: summary.created || null, + updatedAt: summary.lastModified || summary.modified || contentSummary.updatedAt, + metadata: { + product: summary.product || null, + contentTypes: contentSummary.contentTypes, + }, + }; +} + +function getSearchDocuments(payload: LucidSearchResponse): LucidDocumentSummary[] { + return payload.documents || payload.data || payload.results || []; +} + +function getDocumentId(document: LucidDocumentSummary): string | undefined { + return document.id || document.documentId; +} + +function summarizeLucidContents(contents: unknown): { title: string; bodyMarkdown: string; updatedAt: string | null; contentTypes: string[] } { + if (!contents || typeof contents !== "object") { + return { title: "", bodyMarkdown: "", updatedAt: null, contentTypes: [] }; + } + const record = contents as Record; + const title = readString(record, ["title", "name"]); + const updatedAt = readString(record, ["lastModified", "modified", "updatedAt"]) || null; + const contentTypes = Array.from(new Set(collectTypeNames(contents))).slice(0, 20); + return { + title, + bodyMarkdown: readableTextFromUnknown(contents), + updatedAt, + contentTypes, + }; +} + +function readableTextFromUnknown(value: unknown): string { + const text = collectReadableText(value, 0); + return Array.from(new Set(text)).slice(0, 200).join("\n").trim(); +} + +function collectReadableText(value: unknown, depth: number): string[] { + if (depth > 6 || value === null || value === undefined) return []; + if (typeof value === "string") return value.trim() ? [value.trim()] : []; + if (typeof value !== "object") return []; + if (Array.isArray(value)) return value.flatMap((entry) => collectReadableText(entry, depth + 1)); + const record = value as Record; + const direct = readString(record, ["text", "plainText", "content", "label", "title", "name"]); + return [ + direct, + ...Object.values(record).flatMap((entry) => collectReadableText(entry, depth + 1)), + ].filter(Boolean); +} + +function collectTypeNames(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + if (Array.isArray(value)) return value.flatMap(collectTypeNames); + const record = value as Record; + return [ + typeof record.type === "string" ? record.type : "", + typeof record.shapeType === "string" ? record.shapeType : "", + ...Object.values(record).flatMap(collectTypeNames), + ].filter(Boolean); +} + +function readString(record: Record, keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +function normalizeBaseUrl(baseUrl?: string): string { + return (baseUrl?.trim() || LUCID_API_BASE_URL).replace(/\/v1\/?$/i, "").replace(/\/+$/, ""); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function truncatePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact; +} diff --git a/src/services/miro-api-client.ts b/src/services/miro-api-client.ts new file mode 100644 index 0000000000..50c1ad0e8c --- /dev/null +++ b/src/services/miro-api-client.ts @@ -0,0 +1,201 @@ +export interface MiroSearchInput { + boardId?: string; + search?: string; + itemTypes?: string[]; + externalIds?: string[]; + limit: number; + baseUrl?: string; +} + +export interface MiroCanvasItem { + id: string; + boardId: string; + title: string; + type: string; + url: string; + bodyMarkdown: string; + createdAt: string | null; + updatedAt: string | null; + metadata: Record; +} + +const MIRO_API_BASE_URL = "https://api.miro.com/v2"; + +interface MiroListResponse { + data?: T[]; +} + +interface MiroBoard { + id?: string; + name?: string; + viewLink?: string; + modifiedAt?: string | null; + createdAt?: string | null; + team?: { id?: string; name?: string }; +} + +interface MiroRawItem { + id?: string; + type?: string; + links?: { self?: string; related?: string }; + data?: Record; + position?: Record; + geometry?: Record; + createdAt?: string | null; + modifiedAt?: string | null; +} + +export async function searchBoards(token: string, input: MiroSearchInput): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl); + if (input.boardId?.trim()) { + return getBoardItems(token, input.boardId.trim(), input, baseUrl); + } + + const boards = await listBoards(token, input, baseUrl); + return boards + .filter((board): board is MiroBoard & { id: string } => typeof board.id === "string" && board.id.trim().length > 0) + .slice(0, input.limit) + .map((board) => toBoardItem(board)); +} + +export async function getBoardItems( + token: string, + boardId: string, + input: Omit, + baseUrl = normalizeBaseUrl(input.baseUrl), +): Promise { + const url = new URL(`${baseUrl}/boards/${encodeURIComponent(boardId)}/items`); + url.searchParams.set("limit", String(input.limit)); + if (input.itemTypes && input.itemTypes.length > 0) { + url.searchParams.set("type", input.itemTypes.join(",")); + } + const payload = await requestMiro>(url.toString(), token); + const externalIds = new Set(uniqueStrings(input.externalIds || [])); + const itemResults = (payload.data || []) + .filter((item): item is MiroRawItem & { id: string } => typeof item.id === "string" && item.id.trim().length > 0) + .filter((item) => externalIds.size === 0 || externalIds.has(item.id)) + .slice(0, input.limit) + .map((item) => toCanvasItem(boardId, item)); + const boardItem = syntheticBoardItem(boardId); + if (externalIds.size > 0) { + return externalIds.has(boardId) ? [boardItem, ...itemResults].slice(0, input.limit) : itemResults; + } + return [boardItem, ...itemResults].slice(0, input.limit); +} + +async function listBoards(token: string, input: MiroSearchInput, baseUrl: string): Promise { + const url = new URL(`${baseUrl}/boards`); + url.searchParams.set("limit", String(input.limit)); + if (input.search?.trim()) { + url.searchParams.set("query", input.search.trim()); + } + const payload = await requestMiro>(url.toString(), token); + return payload.data || []; +} + +async function requestMiro(url: string, token: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${token}`, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Miro API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + return await response.json() as T; +} + +function toBoardItem(board: MiroBoard & { id: string }): MiroCanvasItem { + return { + id: board.id, + boardId: board.id, + title: board.name || "Untitled Miro board", + type: "board", + url: board.viewLink || `https://miro.com/app/board/${board.id}/`, + bodyMarkdown: "", + createdAt: board.createdAt || null, + updatedAt: board.modifiedAt || null, + metadata: { + boardId: board.id, + teamId: board.team?.id || null, + teamName: board.team?.name || null, + }, + }; +} + +function syntheticBoardItem(boardId: string): MiroCanvasItem { + return { + id: boardId, + boardId, + title: `Miro board ${boardId}`, + type: "board", + url: `https://miro.com/app/board/${boardId}/`, + bodyMarkdown: "", + createdAt: null, + updatedAt: null, + metadata: { + boardId, + }, + }; +} + +function toCanvasItem(boardId: string, item: MiroRawItem & { id: string }): MiroCanvasItem { + const type = item.type || "item"; + const title = readText(item.data, ["title", "name", "content", "text", "plainText"]) || `${type}:${item.id}`; + return { + id: item.id, + boardId, + title, + type, + url: item.links?.self || `https://miro.com/app/board/${boardId}/?moveToWidget=${item.id}`, + bodyMarkdown: dataToMarkdown(item.data), + createdAt: item.createdAt || null, + updatedAt: item.modifiedAt || null, + metadata: { + boardId, + type, + position: item.position || null, + geometry: item.geometry || null, + }, + }; +} + +function dataToMarkdown(data: Record | undefined): string { + if (!data) return ""; + const lines = [ + readText(data, ["title", "name"]), + readText(data, ["content", "text", "plainText", "description"]), + ].filter(Boolean); + return Array.from(new Set(lines)).join("\n\n").trim(); +} + +function readText(data: Record | undefined, keys: string[]): string { + if (!data) return ""; + for (const key of keys) { + const value = data[key]; + if (typeof value === "string" && value.trim()) { + return stripHtml(value); + } + } + return ""; +} + +function stripHtml(value: string): string { + return value.replace(//gi, "\n").replace(/<[^>]+>/g, "").replace(/\r\n/g, "\n").trim(); +} + +function normalizeBaseUrl(baseUrl?: string): string { + return (baseUrl?.trim() || MIRO_API_BASE_URL).replace(/\/+$/, ""); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function truncatePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact; +} diff --git a/src/services/mural-api-client.ts b/src/services/mural-api-client.ts new file mode 100644 index 0000000000..5b676b456f --- /dev/null +++ b/src/services/mural-api-client.ts @@ -0,0 +1,148 @@ +export interface MuralSearchInput { + workspaceId?: string; + muralId?: string; + search?: string; + externalIds?: string[]; + limit: number; + baseUrl?: string; +} + +export interface MuralItem { + id: string; + workspaceId: string | null; + title: string; + url: string; + bodyMarkdown: string; + createdAt: string | null; + updatedAt: string | null; + metadata: Record; +} + +const MURAL_API_BASE_URL = "https://app.mural.co/api/public/v1"; + +interface MuralListResponse { + value?: MuralRaw[]; + data?: MuralRaw[]; + murals?: MuralRaw[]; +} + +interface MuralRaw { + id?: string; + muralId?: string; + title?: string; + name?: string; + url?: string; + visitorUrl?: string; + thumbnailUrl?: string; + workspaceId?: string; + createdOn?: string | null; + createdAt?: string | null; + updatedOn?: string | null; + updatedAt?: string | null; + description?: string | null; + content?: unknown; +} + +export async function searchMurals(token: string, input: MuralSearchInput): Promise { + const ids = uniqueStrings([...(input.externalIds || []), input.muralId || ""]); + if (ids.length > 0) { + return getMurals(token, ids, input); + } + if (!input.workspaceId?.trim()) { + throw new Error("Mural workspace ID is required for mural search."); + } + const baseUrl = normalizeBaseUrl(input.baseUrl); + const payload = await requestMural( + `${baseUrl}/workspaces/${encodeURIComponent(input.workspaceId.trim())}/murals`, + token, + ); + return getMuralList(payload) + .filter((mural): mural is MuralRaw & { id: string } => typeof getMuralId(mural) === "string") + .filter((mural) => !input.search?.trim() || getMuralTitle(mural).toLowerCase().includes(input.search.trim().toLowerCase())) + .slice(0, input.limit) + .map((mural) => toMuralItem(mural, input.workspaceId || null)); +} + +export async function getMurals(token: string, muralIds: string[], input: Omit): Promise { + const items: MuralItem[] = []; + const baseUrl = normalizeBaseUrl(input.baseUrl); + for (const muralId of uniqueStrings(muralIds).slice(0, input.limit)) { + const mural = await requestMural(`${baseUrl}/murals/${encodeURIComponent(muralId)}`, token); + items.push(toMuralItem(mural, input.workspaceId || mural.workspaceId || null, muralId)); + } + return items; +} + +async function requestMural(url: string, token: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${token}`, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Mural API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + return await response.json() as T; +} + +function toMuralItem(mural: MuralRaw, workspaceId: string | null, fallbackId?: string): MuralItem { + const id = getMuralId(mural) || fallbackId || "unknown-mural"; + const contentMarkdown = readableTextFromUnknown(mural.content); + return { + id, + workspaceId, + title: getMuralTitle(mural) || `Mural ${id}`, + url: mural.url || mural.visitorUrl || `https://app.mural.co/t/${workspaceId || "workspace"}/m/${id}`, + bodyMarkdown: [mural.description || "", contentMarkdown].filter(Boolean).join("\n\n").trim(), + createdAt: mural.createdAt || mural.createdOn || null, + updatedAt: mural.updatedAt || mural.updatedOn || null, + metadata: { + workspaceId, + thumbnailUrl: mural.thumbnailUrl || null, + limitedMetadata: true, + }, + }; +} + +function getMuralList(payload: MuralListResponse): MuralRaw[] { + return payload.value || payload.data || payload.murals || []; +} + +function getMuralId(mural: MuralRaw): string | undefined { + return mural.id || mural.muralId; +} + +function getMuralTitle(mural: MuralRaw): string { + return mural.title || mural.name || ""; +} + +function readableTextFromUnknown(value: unknown): string { + if (!value) return ""; + if (typeof value === "string") return value.trim(); + if (Array.isArray(value)) return value.flatMap(readableTextFromUnknown).filter(Boolean).slice(0, 200).join("\n"); + if (typeof value !== "object") return ""; + const record = value as Record; + const direct = ["text", "plainText", "content", "title", "name"] + .map((key) => record[key]) + .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); + return [ + direct || "", + ...Object.values(record).map(readableTextFromUnknown), + ].filter(Boolean).slice(0, 200).join("\n"); +} + +function normalizeBaseUrl(baseUrl?: string): string { + return (baseUrl?.trim() || MURAL_API_BASE_URL).replace(/\/+$/, ""); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function truncatePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact; +} diff --git a/src/services/sprint-issue-service.ts b/src/services/sprint-issue-service.ts index 02c54551ef..c8b7fb2cfa 100644 --- a/src/services/sprint-issue-service.ts +++ b/src/services/sprint-issue-service.ts @@ -23,6 +23,10 @@ import * as jiraApiClient from "./jira-api-client.js"; import * as notionApiClient from "./notion-api-client.js"; import * as asanaApiClient from "./asana-api-client.js"; import * as linearApiClient from "./linear-api-client.js"; +import * as miroApiClient from "./miro-api-client.js"; +import * as lucidApiClient from "./lucid-api-client.js"; +import * as figmaApiClient from "./figma-api-client.js"; +import * as muralApiClient from "./mural-api-client.js"; export interface IssueSearchInput { provider?: LinkedIssueProvider; @@ -34,6 +38,11 @@ export interface IssueSearchInput { teamId?: string; teamKey?: string; databaseId?: string; + boardId?: string; + documentId?: string; + fileKey?: string; + muralId?: string; + itemTypes?: string[]; projectKey?: string; search?: string; state?: RepositoryIssueSearchInput["state"] | string; @@ -68,6 +77,10 @@ interface IssueServiceDeps { notionApiClient?: typeof notionApiClient; asanaApiClient?: typeof asanaApiClient; linearApiClient?: typeof linearApiClient; + miroApiClient?: typeof miroApiClient; + lucidApiClient?: typeof lucidApiClient; + figmaApiClient?: typeof figmaApiClient; + muralApiClient?: typeof muralApiClient; } interface LocalCommandResult { @@ -193,6 +206,22 @@ export class SprintIssueService { return this.searchLinearIssues(searchInput, settings, limit); } + if (provider === "miro") { + return this.searchMiroSources(searchInput, settings, limit); + } + + if (provider === "lucid") { + return this.searchLucidDocuments(searchInput, settings, limit); + } + + if (provider === "figma") { + return this.searchFigmaFiles(searchInput, settings, limit); + } + + if (provider === "mural") { + return this.searchMuralSources(searchInput, settings, limit); + } + const target = resolveIssueTarget(project, searchInput, provider); if (target.provider === "github") { return this.searchGitHubIssues({ @@ -270,6 +299,14 @@ export class SprintIssueService { contexts.push(await this.getAsanaPromptContext(issue, settings)); } else if (issue.provider === "linear") { contexts.push(await this.getLinearPromptContext(issue, settings)); + } else if (issue.provider === "miro") { + contexts.push(await this.getMiroPromptContext(issue, settings)); + } else if (issue.provider === "lucid") { + contexts.push(await this.getLucidPromptContext(issue, settings)); + } else if (issue.provider === "figma") { + contexts.push(await this.getFigmaPromptContext(issue, settings)); + } else if (issue.provider === "mural") { + contexts.push(await this.getMuralPromptContext(issue, settings)); } else { contexts.push(await this.getJiraIssuePromptContext(issue, settings)); } @@ -609,6 +646,105 @@ export class SprintIssueService { return items.map((item) => normalizeLinearIssue(item)); } + private async searchMiroSources( + input: IssueSearchInput, + settings: DashboardSettings, + limit: number, + ): Promise { + const token = settings.miro.apiToken.trim(); + if (!token) { + throw new Error("Miro API token must be configured in Settings -> Integrations."); + } + const boardId = input.boardId || settings.miro.boardId || undefined; + if (!boardId && !input.search) { + throw new Error("Miro board ID or search query must be configured in Settings -> Integrations."); + } + const client = this.deps.miroApiClient ?? miroApiClient; + const items = await client.searchBoards(token, { + boardId, + search: input.search, + itemTypes: input.itemTypes || [], + externalIds: input.externalIds, + limit: effectiveImporterLimit(limit, settings.miro.defaultSearchLimit), + baseUrl: settings.miro.baseUrl || undefined, + }); + return items.map((item) => normalizeMiroItem(item)); + } + + private async searchLucidDocuments( + input: IssueSearchInput, + settings: DashboardSettings, + limit: number, + ): Promise { + const token = settings.lucid.apiToken.trim(); + if (!token) { + throw new Error("Lucid API token must be configured in Settings -> Integrations."); + } + const documentId = input.documentId || settings.lucid.documentId || undefined; + if (!documentId && !input.search && !(input.externalIds && input.externalIds.length > 0)) { + throw new Error("Lucid document ID or search query must be configured in Settings -> Integrations."); + } + const client = this.deps.lucidApiClient ?? lucidApiClient; + const items = await client.searchDocuments(token, { + documentId, + search: input.search, + externalIds: input.externalIds, + limit: effectiveImporterLimit(limit, settings.lucid.defaultSearchLimit), + baseUrl: settings.lucid.baseUrl || undefined, + }); + return items.map((item) => normalizeLucidDocument(item)); + } + + private async searchFigmaFiles( + input: IssueSearchInput, + settings: DashboardSettings, + limit: number, + ): Promise { + const token = settings.figma.apiToken.trim(); + if (!token) { + throw new Error("Figma API token must be configured in Settings -> Integrations."); + } + const fileKey = input.fileKey || settings.figma.fileKey || undefined; + if (!fileKey && !(input.externalIds && input.externalIds.length > 0)) { + throw new Error("Figma file key must be configured in Settings -> Integrations."); + } + const client = this.deps.figmaApiClient ?? figmaApiClient; + const items = await client.getFiles(token, { + fileKey, + externalIds: input.externalIds, + includeConversation: input.includeConversation === true, + limit: effectiveImporterLimit(limit, settings.figma.defaultSearchLimit), + baseUrl: settings.figma.baseUrl || undefined, + }); + return items.map((item) => normalizeFigmaFile(item)); + } + + private async searchMuralSources( + input: IssueSearchInput, + settings: DashboardSettings, + limit: number, + ): Promise { + const token = settings.mural.apiToken.trim(); + if (!token) { + throw new Error("Mural API token must be configured in Settings -> Integrations."); + } + const workspaceId = input.workspaceId || settings.mural.workspaceId || undefined; + const muralId = input.muralId || settings.mural.boardId || undefined; + if (!workspaceId && !muralId && !(input.externalIds && input.externalIds.length > 0)) { + throw new Error("Mural workspace ID or mural ID must be configured in Settings -> Integrations."); + } + const client = this.deps.muralApiClient ?? muralApiClient; + const items = await client.searchMurals(token, { + workspaceId, + muralId, + search: input.search, + externalIds: input.externalIds, + limit: effectiveImporterLimit(limit, settings.mural.defaultSearchLimit), + baseUrl: settings.mural.baseUrl || undefined, + }); + return items.map((item) => normalizeMuralItem(item)); + } + private async getGitHubIssuePromptContext(input: IssuePromptContextInput, tokenValue: string): Promise { const token = tokenValue.trim(); if (!token) { @@ -892,6 +1028,132 @@ export class SprintIssueService { }); } + private async getMiroPromptContext(input: IssuePromptContextInput, settings: DashboardSettings): Promise { + const token = settings.miro.apiToken.trim(); + if (!token) { + throw new Error("Miro API token must be configured in Settings -> Integrations."); + } + const boardId = input.repository || settings.miro.boardId; + if (!boardId.trim()) { + throw new Error("Miro board ID is required for prompt context."); + } + const externalId = requireExternalPromptId(input, "Miro"); + const client = this.deps.miroApiClient ?? miroApiClient; + const items = await client.getBoardItems(token, boardId, { + externalIds: input.sourceKind === "board" ? [] : [externalId], + limit: input.sourceKind === "board" ? 50 : 1, + baseUrl: settings.miro.baseUrl || undefined, + }); + const item = input.sourceKind === "board" + ? items.find((candidate) => candidate.id === boardId) || items[0] + : items[0]; + if (!item) { + throw new Error(`Miro source not found: ${externalId}`); + } + const boardBody = input.sourceKind === "board" + ? formatCanvasItemListMarkdown(items.filter((candidate) => candidate.id !== boardId)) + : item.bodyMarkdown; + return buildIssuePromptContext(input, { + title: item.title, + url: item.url, + state: "open", + body: boardBody, + author: null, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + labels: [item.type], + assignees: [], + conversationMarkdown: "", + includeConversation: false, + }); + } + + private async getLucidPromptContext(input: IssuePromptContextInput, settings: DashboardSettings): Promise { + const token = settings.lucid.apiToken.trim(); + if (!token) { + throw new Error("Lucid API token must be configured in Settings -> Integrations."); + } + const externalId = requireExternalPromptId(input, "Lucid"); + const client = this.deps.lucidApiClient ?? lucidApiClient; + const item = (await client.getDocuments(token, [externalId], { + limit: 1, + baseUrl: settings.lucid.baseUrl || undefined, + }))[0]; + if (!item) { + throw new Error(`Lucid document not found: ${externalId}`); + } + return buildIssuePromptContext(input, { + title: item.title, + url: item.url, + state: "open", + body: item.bodyMarkdown, + author: null, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + labels: ["document"], + assignees: [], + conversationMarkdown: "", + includeConversation: false, + }); + } + + private async getFigmaPromptContext(input: IssuePromptContextInput, settings: DashboardSettings): Promise { + const token = settings.figma.apiToken.trim(); + if (!token) { + throw new Error("Figma API token must be configured in Settings -> Integrations."); + } + const externalId = requireExternalPromptId(input, "Figma"); + const client = this.deps.figmaApiClient ?? figmaApiClient; + const item = await client.getFile(token, externalId, { + includeConversation: input.includeConversation !== false, + limit: 1, + baseUrl: settings.figma.baseUrl || undefined, + }); + return buildIssuePromptContext(input, { + title: item.title, + url: item.url, + state: "open", + body: item.bodyMarkdown, + author: null, + createdAt: null, + updatedAt: item.updatedAt, + labels: ["file"], + assignees: [], + conversationMarkdown: item.conversationMarkdown, + includeConversation: input.includeConversation !== false, + }); + } + + private async getMuralPromptContext(input: IssuePromptContextInput, settings: DashboardSettings): Promise { + const token = settings.mural.apiToken.trim(); + if (!token) { + throw new Error("Mural API token must be configured in Settings -> Integrations."); + } + const externalId = requireExternalPromptId(input, "Mural"); + const client = this.deps.muralApiClient ?? muralApiClient; + const item = (await client.getMurals(token, [externalId], { + workspaceId: input.repository !== "murals" ? input.repository : settings.mural.workspaceId || undefined, + limit: 1, + baseUrl: settings.mural.baseUrl || undefined, + }))[0]; + if (!item) { + throw new Error(`Mural source not found: ${externalId}`); + } + return buildIssuePromptContext(input, { + title: item.title, + url: item.url, + state: "open", + body: item.bodyMarkdown, + author: null, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + labels: ["mural", "beta-limited"], + assignees: [], + conversationMarkdown: "", + includeConversation: false, + }); + } + private requireProject(projectId: string): ProjectSummary { const project = this.deps.projectManagementRepository.getProject(projectId); if (!project) { @@ -1011,7 +1273,7 @@ interface BuildIssuePromptContextOptions { function resolveIssueProvider(project: ProjectSummary, input: IssueSearchInput): LinkedIssueProvider { const provider = input.provider || project.gitProvider; if (provider === "local" || !isIssueImportProvider(provider)) { - throw new Error("Select a GitHub, GitLab, Jira, Notion, Asana, or Linear provider before importing issues."); + throw new Error("Select a GitHub, GitLab, Jira, Notion, Asana, Linear, Miro, Lucid, Figma, or Mural provider before importing issues."); } return provider; } @@ -1079,16 +1341,17 @@ function buildExplicitIssuePromptInputs( if (externalProvider) { const externalIds = collectExternalIds(input, externalProvider); for (const externalId of externalIds) { + const sourceKind = resolveExplicitSourceKind(externalProvider, input, externalId); contexts.push({ provider: externalProvider, sourceProvider: externalProvider, - sourceKind: externalProvider === "notion" && input.databaseId === externalId ? "database" : defaultSourceKindForProvider(externalProvider), + sourceKind, externalId, hostDomain: defaultHostForExternalProvider(externalProvider), repository: defaultRepositoryForExternalProvider(externalProvider, input, settings), issueNumber: null, - issueKey: displayKeyForExternalProvider(externalProvider, externalId, externalProvider === "notion" && input.databaseId === externalId ? "database" : undefined), - title: displayKeyForExternalProvider(externalProvider, externalId, externalProvider === "notion" && input.databaseId === externalId ? "database" : undefined), + issueKey: displayKeyForExternalProvider(externalProvider, externalId, sourceKind), + title: displayKeyForExternalProvider(externalProvider, externalId, sourceKind), url: defaultExternalUrl(externalProvider, externalId), includeConversation: input.includeConversation !== false, }); @@ -1149,6 +1412,18 @@ function collectExternalIds(input: IssueSearchInput, provider: LinkedIssueProvid if (provider === "notion" && input.databaseId) { values.push(input.databaseId); } + if (provider === "miro" && input.boardId) { + values.push(input.boardId); + } + if (provider === "lucid" && input.documentId) { + values.push(input.documentId); + } + if (provider === "figma" && input.fileKey) { + values.push(input.fileKey); + } + if (provider === "mural" && input.muralId) { + values.push(input.muralId); + } return uniqueStrings(values); } @@ -1292,6 +1567,125 @@ function normalizeLinearIssue(item: linearApiClient.LinearIssueItem): Repository }; } +function normalizeMiroItem(item: miroApiClient.MiroCanvasItem): RepositoryIssueSearchResult { + const sourceKind = item.type === "board" ? "board" : "canvas"; + return { + provider: "miro", + sourceProvider: "miro", + sourceKind, + externalId: item.id, + hostDomain: "miro.com", + repository: item.boardId, + issueNumber: null, + issueKey: displayKeyForExternalProvider("miro", item.id, sourceKind), + title: item.title, + url: item.url, + state: "open", + labels: [item.type], + assignees: [], + bodyPreview: truncatePreview(item.bodyMarkdown), + issueBodyMarkdown: item.bodyMarkdown, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + issueAuthor: null, + issueReporter: null, + issueMilestone: null, + issueType: item.type, + issuePriority: null, + issueCommentCount: null, + metadata: item.metadata, + }; +} + +function normalizeLucidDocument(item: lucidApiClient.LucidDocumentItem): RepositoryIssueSearchResult { + return { + provider: "lucid", + sourceProvider: "lucid", + sourceKind: "document", + externalId: item.id, + hostDomain: "lucid.app", + repository: "documents", + issueNumber: null, + issueKey: displayKeyForExternalProvider("lucid", item.id), + title: item.title, + url: item.url, + state: "open", + labels: ["document"], + assignees: [], + bodyPreview: truncatePreview(item.bodyMarkdown), + issueBodyMarkdown: item.bodyMarkdown, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + issueAuthor: null, + issueReporter: null, + issueMilestone: null, + issueType: "Document", + issuePriority: null, + issueCommentCount: null, + metadata: item.metadata, + }; +} + +function normalizeFigmaFile(item: figmaApiClient.FigmaFileItem): RepositoryIssueSearchResult { + return { + provider: "figma", + sourceProvider: "figma", + sourceKind: "file", + externalId: item.key, + hostDomain: "figma.com", + repository: "files", + issueNumber: null, + issueKey: displayKeyForExternalProvider("figma", item.key), + title: item.title, + url: item.url, + state: "open", + labels: ["file"], + assignees: [], + bodyPreview: truncatePreview(item.bodyMarkdown), + issueBodyMarkdown: item.bodyMarkdown, + issueConversationMarkdown: item.conversationMarkdown, + includeConversation: item.conversationMarkdown.trim().length > 0, + createdAt: null, + updatedAt: item.updatedAt, + issueAuthor: null, + issueReporter: null, + issueMilestone: null, + issueType: "File", + issuePriority: null, + issueCommentCount: item.conversationMarkdown ? item.conversationMarkdown.split("##### Comment ").length - 1 : 0, + metadata: item.metadata, + }; +} + +function normalizeMuralItem(item: muralApiClient.MuralItem): RepositoryIssueSearchResult { + return { + provider: "mural", + sourceProvider: "mural", + sourceKind: "canvas", + externalId: item.id, + hostDomain: "app.mural.co", + repository: item.workspaceId || "murals", + issueNumber: null, + issueKey: displayKeyForExternalProvider("mural", item.id), + title: item.title, + url: item.url, + state: "open", + labels: ["mural", "beta-limited"], + assignees: [], + bodyPreview: truncatePreview(item.bodyMarkdown), + issueBodyMarkdown: item.bodyMarkdown, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + issueAuthor: null, + issueReporter: null, + issueMilestone: null, + issueType: "Mural", + issuePriority: null, + issueCommentCount: null, + metadata: item.metadata, + }; +} + function parseIssueNumberFromJiraKey(issueKey: string): number | null { const match = issueKey.trim().match(/-(\d+)$/); if (!match?.[1]) { @@ -1320,6 +1714,11 @@ function normalizeIssueSearchInput(input: IssueSearchInput): IssueSearchInput { teamId: normalizeOptionalString(input.teamId), teamKey: normalizeOptionalString(input.teamKey), databaseId: normalizeOptionalString(input.databaseId), + boardId: normalizeOptionalString(input.boardId), + documentId: normalizeOptionalString(input.documentId), + fileKey: normalizeOptionalString(input.fileKey), + muralId: normalizeOptionalString(input.muralId), + itemTypes: normalizeStringList(input.itemTypes), projectKey: normalizeOptionalString(input.projectKey), search: normalizeOptionalString(input.search), state: normalizeIssueStateValue(input.state), @@ -1542,39 +1941,61 @@ function defaultHostForProvider(provider: string): string { return provider === "gitlab" ? "gitlab.com" : "github.com"; } -function isIssueImportProvider(provider: LinkedIssueProvider | undefined): provider is "github" | "gitlab" | "jira" | "notion" | "asana" | "linear" { +function isIssueImportProvider(provider: LinkedIssueProvider | undefined): provider is LinkedIssueProvider { return provider === "github" || provider === "gitlab" || provider === "jira" || provider === "notion" || provider === "asana" - || provider === "linear"; + || provider === "linear" + || provider === "miro" + || provider === "lucid" + || provider === "figma" + || provider === "mural"; } -function isExternalIssueImportProvider(provider: LinkedIssueProvider | undefined): provider is "notion" | "asana" | "linear" { - return provider === "notion" || provider === "asana" || provider === "linear"; +function isExternalIssueImportProvider(provider: LinkedIssueProvider | undefined): provider is Exclude { + return provider === "notion" + || provider === "asana" + || provider === "linear" + || provider === "miro" + || provider === "lucid" + || provider === "figma" + || provider === "mural"; } -function defaultSourceKindForProvider(provider: LinkedIssueProvider): "issue" | "task" | "page" | "database" { +function defaultSourceKindForProvider(provider: LinkedIssueProvider): NonNullable { if (provider === "asana") return "task"; if (provider === "notion") return "page"; + if (provider === "miro") return "board"; + if (provider === "lucid") return "document"; + if (provider === "figma") return "file"; + if (provider === "mural") return "canvas"; return "issue"; } -function defaultHostForExternalProvider(provider: "notion" | "asana" | "linear"): string { +function defaultHostForExternalProvider(provider: Exclude): string { if (provider === "notion") return "notion.so"; if (provider === "asana") return "app.asana.com"; - return "linear.app"; + if (provider === "linear") return "linear.app"; + if (provider === "miro") return "miro.com"; + if (provider === "lucid") return "lucid.app"; + if (provider === "figma") return "figma.com"; + return "app.mural.co"; } function defaultRepositoryForExternalProvider( - provider: "notion" | "asana" | "linear", + provider: Exclude, input: IssueSearchInput, settings: DashboardSettings, ): string { if (provider === "notion") return input.databaseId || settings.notion.databaseId || "workspace"; if (provider === "asana") return input.providerProjectId || input.projectId || settings.asana.projectId || input.workspaceId || settings.asana.workspaceId || "tasks"; - return input.teamKey || settings.linear.teamKey || input.teamId || settings.linear.teamId || "issues"; + if (provider === "linear") return input.teamKey || settings.linear.teamKey || input.teamId || settings.linear.teamId || "issues"; + if (provider === "miro") return input.boardId || settings.miro.boardId || "boards"; + if (provider === "lucid") return "documents"; + if (provider === "figma") return "files"; + return input.workspaceId || settings.mural.workspaceId || "murals"; } function displayKeyForExternalProvider(provider: LinkedIssueProvider, externalId: string, sourceKind?: string): string { @@ -1587,13 +2008,40 @@ function displayKeyForExternalProvider(provider: LinkedIssueProvider, externalId if (provider === "linear") { return externalId.includes("-") && /^[A-Z]+-\d+$/i.test(externalId) ? externalId.toUpperCase() : `issue:${shortExternalId(externalId)}`; } + if (provider === "miro") { + return `${sourceKind === "board" ? "board" : "item"}:${shortExternalId(externalId)}`; + } + if (provider === "lucid") { + return `document:${shortExternalId(externalId)}`; + } + if (provider === "figma") { + return `file:${shortExternalId(externalId)}`; + } + if (provider === "mural") { + return `mural:${shortExternalId(externalId)}`; + } return externalId; } -function defaultExternalUrl(provider: "notion" | "asana" | "linear", externalId: string): string { +function defaultExternalUrl(provider: Exclude, externalId: string): string { if (provider === "notion") return `https://www.notion.so/${externalId.replace(/-/g, "")}`; if (provider === "asana") return `https://app.asana.com/0/0/${externalId}`; - return `https://linear.app/issue/${externalId}`; + if (provider === "linear") return `https://linear.app/issue/${externalId}`; + if (provider === "miro") return `https://miro.com/app/board/${externalId}/`; + if (provider === "lucid") return `https://lucid.app/documents#/documents/${externalId}`; + if (provider === "figma") return `https://www.figma.com/file/${externalId}`; + return `https://app.mural.co/mural/${externalId}`; +} + +function resolveExplicitSourceKind( + provider: Exclude, + input: IssueSearchInput, + externalId: string, +): NonNullable { + if (provider === "notion" && input.databaseId === externalId) return "database"; + if (provider === "miro" && input.boardId === externalId) return "board"; + if (provider === "mural") return "canvas"; + return defaultSourceKindForProvider(provider); } function shortExternalId(externalId: string): string { @@ -1615,6 +2063,18 @@ function requireExternalPromptId(input: IssuePromptContextInput, providerName: s return externalId; } +function formatCanvasItemListMarkdown(items: miroApiClient.MiroCanvasItem[]): string { + return items + .filter((item) => item.title.trim() || item.bodyMarkdown.trim()) + .slice(0, 50) + .map((item) => { + const body = item.bodyMarkdown.trim(); + return `- ${item.title || item.id}${item.type ? ` (${item.type})` : ""}${body ? `: ${body}` : ""}`; + }) + .join("\n") + .trim(); +} + export function clampLimit(limit: number | undefined): number { if (!Number.isFinite(limit)) { return 30; diff --git a/tests/backend/mcp/management-sprint-actions.test.ts b/tests/backend/mcp/management-sprint-actions.test.ts index ece77c1d2d..b51dbbb235 100644 --- a/tests/backend/mcp/management-sprint-actions.test.ts +++ b/tests/backend/mcp/management-sprint-actions.test.ts @@ -277,7 +277,7 @@ describe("SprintActions", () => { projectId: "p1", provider: "bitbucket", search: "bug", - }))).rejects.toThrow("Invalid value for provider. Must be one of: github, gitlab, jira, notion, asana, linear"); + }))).rejects.toThrow("Invalid value for provider. Must be one of: github, gitlab, jira, notion, asana, linear, miro, lucid, figma, mural"); expect(sprintIssueService.searchIssues).not.toHaveBeenCalled(); }); @@ -412,7 +412,7 @@ describe("SprintActions", () => { labels: ["integration"], teamKey: "lin", providerProjectId: "project-1", - includeConversation: undefined, + includeConversation: true, limit: 5, })); expect(projectRepo.replaceSprintLinkedIssues).not.toHaveBeenCalled(); @@ -535,6 +535,74 @@ describe("SprintActions", () => { }); }); + it("parses canvas import fields and imports explicit Figma file context", async () => { + const contexts = [{ + provider: "figma", + sourceProvider: "figma", + sourceKind: "file", + externalId: "file-1", + hostDomain: "figma.com", + repository: "files", + issueNumber: null, + issueKey: "file:file-1", + title: "Design spec", + url: "https://www.figma.com/file/file-1", + state: "open", + labels: ["file"], + assignees: [], + issueBodyMarkdown: "## Page 1", + issueConversationMarkdown: "##### Comment 1 - @Alice\n\nPlease preserve this layout", + includeConversation: true, + issueAuthor: null, + issueCreatedAt: null, + issueUpdatedAt: "2026-05-02T00:00:00.000Z", + }]; + const linkedRecords = [{ id: "link-1", externalId: "file-1" }]; + vi.mocked(sprintIssueService.getIssuePromptContextsForReferences).mockResolvedValue(contexts as any); + vi.mocked(projectRepo.getSprint).mockReturnValue({ id: "s1", projectId: "p1", goal: "Existing goal" } as any); + vi.mocked(projectRepo.replaceSprintLinkedIssues).mockReturnValue(linkedRecords as any); + vi.mocked(projectRepo.updateSprint).mockReturnValue({ id: "s1", projectId: "p1", goal: "updated" } as any); + + const result = await sprintActions.handleSprintAction(makeArgs("import_issues", { + projectId: "p1", + sprintId: "s1", + provider: "figma", + fileKey: " file-1 ", + boardId: " board-1 ", + documentId: " doc-1 ", + workspaceId: " workspace-1 ", + muralId: " mural-1 ", + itemTypes: [" sticky_note ", "text"], + includeConversation: true, + })); + + expect(sprintIssueService.getIssuePromptContextsForReferences).toHaveBeenCalledWith("p1", expect.objectContaining({ + provider: "figma", + fileKey: "file-1", + boardId: "board-1", + documentId: "doc-1", + workspaceId: "workspace-1", + muralId: "mural-1", + itemTypes: ["sticky_note", "text"], + includeConversation: true, + })); + expect(sprintIssueService.importLinkedIssues).toHaveBeenCalledWith("s1", "p1", [expect.objectContaining({ + provider: "figma", + sourceKind: "file", + externalId: "file-1", + issueNumber: null, + })]); + expect(projectRepo.updateSprint).toHaveBeenCalledWith("s1", { + goal: expect.stringContaining("Please preserve this layout"), + }); + expect(result.result).toMatchObject({ + mode: "explicit", + provider: "figma", + importedContexts: contexts, + linkedIssues: linkedRecords, + }); + }); + it("returns Jira import transition warnings without dropping linked issues", async () => { const contexts = [{ provider: "jira", @@ -801,6 +869,11 @@ describe("SprintActions", () => { teamId: "team-1", teamKey: "LIN", databaseId: "database-1", + boardId: "board-1", + documentId: "document-1", + fileKey: "file-1", + muralId: "mural-1", + itemTypes: ["sticky_note", "text"], includeConversation: true, attachToSprint: true, planAfterImport: true, diff --git a/tests/backend/mcp/mcp-management.test.ts b/tests/backend/mcp/mcp-management.test.ts index 0c9cb8bc5d..4c103f231c 100644 --- a/tests/backend/mcp/mcp-management.test.ts +++ b/tests/backend/mcp/mcp-management.test.ts @@ -405,7 +405,7 @@ describe("ManagementToolHandler", () => { const properties = schema?.properties ?? {}; expect(properties.action?.enum).toContain("import_issues"); - expect(properties.provider?.enum).toEqual(["github", "gitlab", "jira", "notion", "asana", "linear"]); + expect(properties.provider?.enum).toEqual(["github", "gitlab", "jira", "notion", "asana", "linear", "miro", "lucid", "figma", "mural"]); expect(properties.state).toMatchObject({ type: "string" }); expect(properties.labels).toMatchObject({ type: "array", items: { type: "string" } }); expect(properties.issueKeys).toMatchObject({ type: "array", items: { type: "string" } }); diff --git a/tests/backend/server/sprint-issue-search-routes.test.ts b/tests/backend/server/sprint-issue-search-routes.test.ts index 913fb530f9..0b5dad62a9 100644 --- a/tests/backend/server/sprint-issue-search-routes.test.ts +++ b/tests/backend/server/sprint-issue-search-routes.test.ts @@ -108,6 +108,33 @@ describe("sprint issue search routes", () => { })); }); + it("parses canvas provider issue search filters", async () => { + const searchIssues = vi.fn(async () => []); + const app = createApp({ + sprintIssueService: { + searchIssues, + }, + } as unknown as DashboardDependencies); + + const response = await request(app).get( + "/api/projects/project-1/issues?provider=miro&boardId=board-1&documentId=doc-1&fileKey=file-1&workspaceId=workspace-1&muralId=mural-1&itemTypes=sticky_note,text&externalIds=item-1,item-2&includeConversation=true&limit=10" + ); + + expect(response.status).toBe(200); + expect(searchIssues).toHaveBeenCalledWith("project-1", expect.objectContaining({ + provider: "miro", + boardId: "board-1", + documentId: "doc-1", + fileKey: "file-1", + workspaceId: "workspace-1", + muralId: "mural-1", + itemTypes: ["sticky_note", "text"], + externalIds: ["item-1", "item-2"], + includeConversation: true, + limit: 10, + })); + }); + it("rejects invalid issue search enums and dates", async () => { const app = createApp({ sprintIssueService: { searchIssues: vi.fn(async () => []) }, diff --git a/tests/backend/services/sprint-issue-service.test.ts b/tests/backend/services/sprint-issue-service.test.ts index 829a855ca4..6daed0984e 100644 --- a/tests/backend/services/sprint-issue-service.test.ts +++ b/tests/backend/services/sprint-issue-service.test.ts @@ -955,6 +955,329 @@ describe("SprintIssueService", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("searches Miro board items with item type filters and normalized board scope", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const parsed = new URL(url); + expect(parsed.origin).toBe("https://api.miro.com"); + expect(parsed.pathname).toBe("/v2/boards/board-1/items"); + expect(parsed.searchParams.get("type")).toBe("sticky_note,text"); + expect((init?.headers as Record).Authorization).toBe("Bearer miro-token"); + return new Response(JSON.stringify({ + data: [{ + id: "item-1", + type: "sticky_note", + data: { title: "Note title", content: "

Build this flow

" }, + links: { self: "https://miro.com/app/board/board-1/?moveToWidget=item-1" }, + modifiedAt: "2026-05-02T00:00:00.000Z", + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { + getProject: () => project, + } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + miro: { + ...DEFAULT_DASHBOARD_SETTINGS.miro, + apiToken: "miro-token", + boardId: "board-1", + }, + }), + }); + + const issues = await service.searchIssues(project.id, { + provider: "miro", + itemTypes: ["sticky_note", "text"], + limit: 10, + }); + + expect(issues).toEqual([ + expect.objectContaining({ + provider: "miro", + sourceKind: "board", + externalId: "board-1", + issueKey: "board:board-1", + repository: "board-1", + }), + expect.objectContaining({ + provider: "miro", + sourceKind: "canvas", + externalId: "item-1", + issueKey: "item:item-1", + title: "Note title", + issueBodyMarkdown: "Note title\n\nBuild this flow", + updatedAt: "2026-05-02T00:00:00.000Z", + }), + ]); + }); + + it("requires Miro token and board/search configuration before searching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const withoutToken = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + miro: { ...DEFAULT_DASHBOARD_SETTINGS.miro, apiToken: "" }, + }), + }); + await expect(withoutToken.searchIssues(project.id, { provider: "miro", boardId: "board-1" })) + .rejects.toThrow("Miro API token must be configured in Settings -> Integrations."); + + const withoutBoard = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + miro: { ...DEFAULT_DASHBOARD_SETTINGS.miro, apiToken: "miro-token" }, + }), + }); + await expect(withoutBoard.searchIssues(project.id, { provider: "miro" })) + .rejects.toThrow("Miro board ID or search query must be configured in Settings -> Integrations."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("searches Lucid documents and loads readable document contents", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (url === "https://api.lucid.co/documents/search") { + expect(init?.method).toBe("POST"); + expect((init?.headers as Record).Authorization).toBe("Bearer lucid-token"); + expect((init?.headers as Record)["Lucid-Api-Version"]).toBe("1"); + return new Response(JSON.stringify({ + documents: [{ + id: "doc-1", + title: "Architecture diagram", + editUrl: "https://lucid.app/documents/edit/doc-1", + lastModified: "2026-05-02T00:00:00.000Z", + product: "lucidchart", + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "https://api.lucid.co/v1/documents/doc-1/contents") { + return new Response(JSON.stringify({ + title: "Architecture diagram", + pages: [{ title: "System", shapes: [{ type: "process", text: "Import canvas context" }] }], + modified: "2026-05-03T00:00:00.000Z", + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + lucid: { ...DEFAULT_DASHBOARD_SETTINGS.lucid, apiToken: "lucid-token" }, + }), + }); + + const searchResults = await service.searchIssues(project.id, { + provider: "lucid", + search: "architecture", + limit: 5, + }); + expect(searchResults[0]).toEqual(expect.objectContaining({ + provider: "lucid", + sourceKind: "document", + externalId: "doc-1", + issueKey: "document:doc-1", + title: "Architecture diagram", + })); + + const contexts = await service.getIssuePromptContextsForReferences(project.id, { + provider: "lucid", + documentId: "doc-1", + }); + expect(contexts[0]).toEqual(expect.objectContaining({ + provider: "lucid", + issueBodyMarkdown: expect.stringContaining("Import canvas context"), + issueUpdatedAt: "2026-05-03T00:00:00.000Z", + })); + }); + + it("requires Lucid token and document/search configuration before searching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const service = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + lucid: { ...DEFAULT_DASHBOARD_SETTINGS.lucid, apiToken: "" }, + }), + }); + await expect(service.searchIssues(project.id, { provider: "lucid", search: "diagram" })) + .rejects.toThrow("Lucid API token must be configured in Settings -> Integrations."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("imports Figma files with top-level pages and comments when requested", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + expect((init?.headers as Record)["X-Figma-Token"]).toBe("figma-token"); + if (url === "https://api.figma.com/v1/files/file-1") { + return new Response(JSON.stringify({ + name: "Design spec", + lastModified: "2026-05-02T00:00:00.000Z", + document: { + children: [{ + id: "0:1", + name: "Page 1", + type: "CANVAS", + children: [{ id: "1:2", name: "Hero", type: "FRAME" }], + }], + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "https://api.figma.com/v1/files/file-1/comments") { + return new Response(JSON.stringify({ + comments: [{ + id: "comment-1", + message: "Please preserve this layout", + created_at: "2026-05-03T00:00:00.000Z", + user: { handle: "Alice" }, + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + figma: { ...DEFAULT_DASHBOARD_SETTINGS.figma, apiToken: "figma-token", fileKey: "file-1" }, + }), + }); + + const issues = await service.searchIssues(project.id, { + provider: "figma", + includeConversation: true, + }); + + expect(issues[0]).toEqual(expect.objectContaining({ + provider: "figma", + sourceKind: "file", + externalId: "file-1", + issueKey: "file:file-1", + title: "Design spec", + issueBodyMarkdown: expect.stringContaining("## Page 1"), + issueConversationMarkdown: expect.stringContaining("Please preserve this layout"), + updatedAt: "2026-05-02T00:00:00.000Z", + })); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("requires Figma token and file key before importing", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const withoutToken = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + figma: { ...DEFAULT_DASHBOARD_SETTINGS.figma, apiToken: "" }, + }), + }); + await expect(withoutToken.searchIssues(project.id, { provider: "figma", fileKey: "file-1" })) + .rejects.toThrow("Figma API token must be configured in Settings -> Integrations."); + + const withoutFile = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + figma: { ...DEFAULT_DASHBOARD_SETTINGS.figma, apiToken: "figma-token" }, + }), + }); + await expect(withoutFile.searchIssues(project.id, { provider: "figma" })) + .rejects.toThrow("Figma file key must be configured in Settings -> Integrations."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("searches Mural workspace murals and marks beta limited metadata", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + expect((init?.headers as Record).Authorization).toBe("Bearer mural-token"); + if (url === "https://app.mural.co/api/public/v1/workspaces/workspace-1/murals") { + return new Response(JSON.stringify({ + value: [{ + id: "mural-1", + title: "Planning mural", + url: "https://app.mural.co/t/team/m/mural-1", + workspaceId: "workspace-1", + updatedOn: "2026-05-02T00:00:00.000Z", + description: "Mural metadata only", + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "https://app.mural.co/api/public/v1/murals/mural-1") { + return new Response(JSON.stringify({ + id: "mural-1", + title: "Planning mural", + workspaceId: "workspace-1", + description: "Limited content available to token", + content: { widgets: [{ text: "Mural note" }] }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + mural: { ...DEFAULT_DASHBOARD_SETTINGS.mural, apiToken: "mural-token", workspaceId: "workspace-1" }, + }), + }); + + const issues = await service.searchIssues(project.id, { + provider: "mural", + search: "planning", + }); + expect(issues[0]).toEqual(expect.objectContaining({ + provider: "mural", + sourceKind: "canvas", + externalId: "mural-1", + labels: ["mural", "beta-limited"], + metadata: expect.objectContaining({ limitedMetadata: true }), + })); + + const contexts = await service.getIssuePromptContextsForReferences(project.id, { + provider: "mural", + muralId: "mural-1", + workspaceId: "workspace-1", + }); + expect(contexts[0]?.issueBodyMarkdown).toContain("Limited content available to token"); + expect(contexts[0]?.issueBodyMarkdown).toContain("Mural note"); + }); + + it("requires Mural token and workspace or mural ID before searching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const withoutToken = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + mural: { ...DEFAULT_DASHBOARD_SETTINGS.mural, apiToken: "" }, + }), + }); + await expect(withoutToken.searchIssues(project.id, { provider: "mural", workspaceId: "workspace-1" })) + .rejects.toThrow("Mural API token must be configured in Settings -> Integrations."); + + const withoutIds = new SprintIssueService({ + projectManagementRepository: { getProject: () => project } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + mural: { ...DEFAULT_DASHBOARD_SETTINGS.mural, apiToken: "mural-token" }, + }), + }); + await expect(withoutIds.searchIssues(project.id, { provider: "mural" })) + .rejects.toThrow("Mural workspace ID or mural ID must be configured in Settings -> Integrations."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("searches Jira issues through the generic issue service and clamps limits", async () => { const jiraApiClient = { searchIssues: vi.fn(async () => [{